feat(merge): Stash-Option fuer uncommittete Aenderungen im Ziel-Tree
Ein dirty Ziel-Arbeitsverzeichnis gibt jetzt den eigenen Status dirty_target zurueck statt blocked. Die UI bietet daraufhin einen Retry an, der per git merge --autostash stasht, merged und die Aenderungen danach wiederherstellt - opt-in pro Merge: - Approve & Merge: Bestaetigungsdialog - Merge-Modal: Button "Aenderungen stashen und mergen" - MCP (merge_task/review_task) wirft weiterhin, kein autostash dort
This commit is contained in:
@@ -109,9 +109,9 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task SetTaskDoneAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task UnsetTaskDoneAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task DequeueTaskAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
|
||||
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
||||
|
||||
@@ -41,7 +41,7 @@ public class DetailsIslandConflictSeamTests : IDisposable
|
||||
private sealed class ConflictApproveWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[] { "a.cs" }, null));
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
private sealed class ThrowingReviewWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Task.FromException<MergeResultDto?>(new InvalidOperationException("Task is not waiting for review; cannot approve."));
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public TaskCompletionSource<MergeResultDto?> Gate { get; } = new();
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Gate.Task;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Gate.Task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -21,17 +21,22 @@ public class MergeModalViewModelTests
|
||||
public TaskCompletionSource<MergeResultDto> MergeGate { get; } = new();
|
||||
public bool BlockMerge { get; set; }
|
||||
public string? CapturedCommitMessage { get; private set; }
|
||||
public bool CapturedAutostash { get; private set; }
|
||||
|
||||
/// Result for a call with autostash=false; the autostash retry always merges.
|
||||
public MergeResultDto CleanResult { get; set; } = new("merged", new List<string>(), null);
|
||||
|
||||
public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) =>
|
||||
Task.FromResult(Targets);
|
||||
|
||||
public override Task<MergeResultDto> MergeTaskAsync(
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
|
||||
{
|
||||
CapturedCommitMessage = commitMessage;
|
||||
CapturedAutostash = autostash;
|
||||
return BlockMerge
|
||||
? MergeGate.Task
|
||||
: Task.FromResult(new MergeResultDto("merged", new List<string>(), null));
|
||||
: Task.FromResult(autostash ? new MergeResultDto("merged", new List<string>(), null) : CleanResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +95,26 @@ public class MergeModalViewModelTests
|
||||
Assert.True(vm.Merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dirty_target_offers_the_stash_retry_and_merges_with_autostash()
|
||||
{
|
||||
var (vm, worker) = Build();
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
worker.CleanResult = new MergeResultDto("dirty_target", new List<string>(), "target working tree has uncommitted changes");
|
||||
|
||||
await vm.SubmitCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(vm.ShowStashRetry);
|
||||
Assert.False(vm.Merged);
|
||||
Assert.False(worker.CapturedAutostash);
|
||||
|
||||
await vm.StashAndMergeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(worker.CapturedAutostash);
|
||||
Assert.True(vm.Merged);
|
||||
Assert.False(vm.ShowStashRetry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progress_for_another_task_is_ignored()
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ public class TasksIslandApproveReviewTests : IDisposable
|
||||
private sealed class ThrowingWorkerClient : StubWorkerClient
|
||||
{
|
||||
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -392,10 +392,42 @@ public class TaskMergeServiceTests : IDisposable
|
||||
var (svc, _) = BuildService(db);
|
||||
var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);
|
||||
|
||||
Assert.Equal("blocked", result.Status);
|
||||
Assert.Equal(TaskMergeService.StatusDirtyTarget, result.Status);
|
||||
Assert.Contains("uncommitted", result.ErrorMessage ?? "");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_DirtyWorkingTree_WithAutostash_MergesAndRestoresTheChanges()
|
||||
{
|
||||
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));
|
||||
|
||||
// The branch touches a different file than the dirty one, so the stash restores cleanly.
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "from the branch\n");
|
||||
GitRepoFixture.RunGit(wtCtx.WorktreePath, "add", "-A");
|
||||
GitRepoFixture.RunGit(wtCtx.WorktreePath, "commit", "-m", "feat: add feature.txt");
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
|
||||
|
||||
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
var (svc, _) = BuildService(db);
|
||||
var result = await svc.MergeAsync(
|
||||
task.Id, currentBranch, false, "Merge", leaveConflictsInTree: false, CancellationToken.None,
|
||||
autostash: true);
|
||||
|
||||
Assert.True(result.Status == TaskMergeService.StatusMerged, $"{result.Status}: {result.ErrorMessage}");
|
||||
// The branch landed...
|
||||
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feature.txt")));
|
||||
// ...and the stashed work is back in the working tree, still uncommitted.
|
||||
Assert.Equal("dirty\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")).Replace("\r\n", "\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_UntrackedFileInTargetWorkingTree_DoesNotBlock()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user