fix(merge): conventional merge-commit default and live verify progress
The merge commit message was hand-rolled per caller ("Merge task: <title>",
"Merge <branch>", "Merge subtask") and ignored the task's commit type. Every
caller now passes a blank message and TaskMergeService fills in
CommitMessageBuilder.BuildMerge -> {commitType}(list-slug): merge <title> plus the
ClaudeDo-Task trailer; the merge modal prefills it from GetMergeTargets.
A merge whose list has a verify command holds the MergeTask call for minutes (5m46s
on this repo), during which the modal only disabled its button - no spinner, no
message, so a landed merge looked like a dead app. TaskMergeService now broadcasts
MergeProgress(taskId, phase, elapsedSeconds) for the merging and verifying phases
(re-reported every 30s) plus a WorkerLog line when verify starts; the modal shows a
spinner and the localized phase.
This commit is contained in:
@@ -31,6 +31,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<bool>? PrepFinishedEvent;
|
||||
public event Action<string>? RefineStartedEvent;
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
public event Action<string, string, int>? MergeProgressEvent;
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
@@ -55,6 +56,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
|
||||
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||
public void RaiseMergeProgress(string taskId, string phase, int elapsedSeconds) => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds);
|
||||
public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
|
||||
public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId);
|
||||
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
||||
|
||||
@@ -129,7 +129,7 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
|
||||
var fake = new FakeWorkerClient
|
||||
{
|
||||
MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" }),
|
||||
MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" }, "chore(list): merge t"),
|
||||
};
|
||||
|
||||
var vm = BuildVm(fake);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the two things that made a working merge look broken: the prefilled commit message
|
||||
/// (must be the worker's conventional default, not a hand-rolled "Merge task: …") and the
|
||||
/// progress feedback while the post-merge verify gate holds the call open for minutes.
|
||||
/// </summary>
|
||||
public class MergeModalViewModelTests
|
||||
{
|
||||
private sealed class Worker : StubWorkerClient
|
||||
{
|
||||
public MergeTargetsDto? Targets { get; set; } =
|
||||
new("main", new[] { "main", "dev" }, "fix(my-list): merge do the thing");
|
||||
|
||||
public TaskCompletionSource<MergeResultDto> MergeGate { get; } = new();
|
||||
public bool BlockMerge { get; set; }
|
||||
public string? CapturedCommitMessage { get; private set; }
|
||||
|
||||
public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) =>
|
||||
Task.FromResult(Targets);
|
||||
|
||||
public override Task<MergeResultDto> MergeTaskAsync(
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
{
|
||||
CapturedCommitMessage = commitMessage;
|
||||
return BlockMerge
|
||||
? MergeGate.Task
|
||||
: Task.FromResult(new MergeResultDto("merged", new List<string>(), null));
|
||||
}
|
||||
}
|
||||
|
||||
private static (MergeModalViewModel vm, Worker worker) Build()
|
||||
{
|
||||
var worker = new Worker();
|
||||
return (new MergeModalViewModel(worker, new MergeCoordinator()), worker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_prefills_the_workers_default_commit_message()
|
||||
{
|
||||
var (vm, _) = Build();
|
||||
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
|
||||
Assert.Equal("fix(my-list): merge do the thing", vm.CommitMessage);
|
||||
Assert.Equal("main", vm.SelectedBranch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_keeps_local_fallback_when_worker_sends_no_default()
|
||||
{
|
||||
var (vm, worker) = Build();
|
||||
worker.Targets = new MergeTargetsDto("main", new[] { "main" }, "");
|
||||
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(vm.CommitMessage));
|
||||
Assert.NotEqual("", vm.CommitMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_shows_a_phase_while_the_merge_is_pending_and_clears_it_after()
|
||||
{
|
||||
var (vm, worker) = Build();
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
worker.BlockMerge = true;
|
||||
|
||||
var submit = vm.SubmitCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(vm.IsBusy);
|
||||
var merging = vm.ProgressMessage;
|
||||
Assert.False(string.IsNullOrWhiteSpace(merging));
|
||||
|
||||
// The verify gate starts — a distinct phase, so the user sees why this is taking minutes.
|
||||
// (The elapsed time rides in a format argument, which the test localizer drops.)
|
||||
worker.RaiseMergeProgress("task-1", "verifying", 90);
|
||||
Assert.NotEqual(merging, vm.ProgressMessage);
|
||||
|
||||
worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null));
|
||||
await submit;
|
||||
|
||||
Assert.Null(vm.ProgressMessage);
|
||||
Assert.False(vm.IsBusy);
|
||||
Assert.True(vm.Merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progress_for_another_task_is_ignored()
|
||||
{
|
||||
var (vm, worker) = Build();
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
worker.BlockMerge = true;
|
||||
|
||||
var submit = vm.SubmitCommand.ExecuteAsync(null);
|
||||
var merging = vm.ProgressMessage;
|
||||
|
||||
worker.RaiseMergeProgress("some-other-task", "verifying", 42);
|
||||
|
||||
Assert.Equal(merging, vm.ProgressMessage);
|
||||
|
||||
worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null));
|
||||
await submit;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_unsubscribes_so_a_later_broadcast_cannot_revive_the_progress_line()
|
||||
{
|
||||
var (vm, worker) = Build();
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
|
||||
await vm.SubmitCommand.ExecuteAsync(null);
|
||||
worker.RaiseMergeProgress("task-1", "verifying", 10);
|
||||
|
||||
Assert.Null(vm.ProgressMessage);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class WorktreesOverviewReconcileTickTests
|
||||
public override Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
|
||||
=> Task.FromResult(Worktrees.ToList());
|
||||
public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||
=> Task.FromResult<MergeTargetsDto?>(new MergeTargetsDto("main", new[] { "main" }));
|
||||
=> Task.FromResult<MergeTargetsDto?>(new MergeTargetsDto("main", new[] { "main" }, "chore(list): merge t"));
|
||||
}
|
||||
|
||||
private sealed class NoopMergeCoordinator : IMergeCoordinator
|
||||
|
||||
@@ -65,6 +65,39 @@ public class CommitMessageBuilderTests
|
||||
Assert.Equal("ClaudeDo-Task: id-456", lines[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMerge_ConventionalHeaderWithMergeVerbAndTrailer()
|
||||
{
|
||||
var msg = CommitMessageBuilder.BuildMerge("fix", "Lager App", "stop the crash", "id-789");
|
||||
var lines = msg.Split('\n');
|
||||
|
||||
Assert.Equal("fix(lager-app): merge stop the crash", lines[0]);
|
||||
Assert.Equal("", lines[1]);
|
||||
Assert.Equal("ClaudeDo-Task: id-789", lines[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMerge_BlankCommitType_FallsBackToDefault()
|
||||
{
|
||||
var msg = CommitMessageBuilder.BuildMerge(" ", "My List", "do it", "id");
|
||||
Assert.StartsWith("chore(my-list): merge do it", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMerge_ListNameWithoutSluggableChars_DropsScope()
|
||||
{
|
||||
var msg = CommitMessageBuilder.BuildMerge("feat", "!!!", "do it", "id");
|
||||
Assert.StartsWith("feat: merge do it", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMerge_TitleTruncatedTo60()
|
||||
{
|
||||
var msg = CommitMessageBuilder.BuildMerge("feat", "My List", new string('x', 80), "id");
|
||||
var titlePart = msg.Split('\n')[0].Split(": merge ", 2)[1];
|
||||
Assert.Equal(60, titlePart.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Description_TruncatedTo400()
|
||||
{
|
||||
|
||||
@@ -283,6 +283,80 @@ public class TaskMergeServiceTests : IDisposable
|
||||
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_BlankCommitMessage_UsesConventionalDefault()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "feat\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
||||
commitMessage: "", ct: CancellationToken.None);
|
||||
|
||||
Assert.Equal("merged", result.Status);
|
||||
var subject = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%s").Trim();
|
||||
// task.CommitType (not the list default) — same type the task's own commits used.
|
||||
Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", subject);
|
||||
var body = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%b");
|
||||
Assert.Contains($"ClaudeDo-Task: {task.Id}", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_VerifyGate_BroadcastsMergingAndVerifyingPhases()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||
await SeedVerifyCommand(db, list.Id, "echo ok");
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "feat\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var (svc, proxy) = BuildService(db, new FakeVerifyCommandRunner());
|
||||
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
||||
commitMessage: "", ct: CancellationToken.None);
|
||||
|
||||
Assert.Equal("merged", result.Status);
|
||||
Assert.Contains(proxy.Calls, c => c.Method == "MergeProgress"
|
||||
&& (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseMerging);
|
||||
Assert.Contains(proxy.Calls, c => c.Method == "MergeProgress"
|
||||
&& (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseVerifying);
|
||||
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
|
||||
&& c.Args[0] is string s && s.Contains("Verify command running"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTargetsAsync_ReturnsDefaultCommitMessage()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", targets.DefaultCommitMessage.Split('\n')[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches()
|
||||
{
|
||||
|
||||
@@ -113,6 +113,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public event Action<bool>? PrepFinishedEvent;
|
||||
public event Action<string>? RefineStartedEvent;
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
public event Action<string, string, int>? MergeProgressEvent;
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
|
||||
Reference in New Issue
Block a user