Merge branch 'claudedo/295d5d409c194ba28dbb95ab13630832'

This commit is contained in:
mika kuns
2026-08-10 15:04:46 +02:00
9 changed files with 272 additions and 15 deletions
+14 -1
View File
@@ -45,6 +45,16 @@ public sealed class GitService
};
}
/// <summary>
/// The merge base of two refs, or null when git can't find one (e.g. an unresolvable ref) —
/// callers treat that as "can't evaluate", not "no common history".
/// </summary>
public async Task<string?> MergeBaseAsync(string repoDir, string refA, string refB, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["merge-base", refA, refB], ct);
return exitCode == 0 ? stdout.Trim() : null;
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
@@ -73,9 +83,12 @@ public sealed class GitService
}
}
// --untracked-files=all: without it, a brand-new untracked directory collapses into a single
// "?? dir/" entry instead of listing the files inside it — callers matching against specific
// paths (e.g. the untracked-collision guard) need the individual files.
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain", "--untracked-files=all"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return stdout;
+2 -2
View File
@@ -668,7 +668,7 @@
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
@@ -683,7 +683,7 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
"listSettings": { "untitled": "Unbenannt" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben." },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" },
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
},
+2 -2
View File
@@ -668,7 +668,7 @@
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
@@ -683,7 +683,7 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
"listSettings": { "untitled": "Untitled" },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory." },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" },
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
},
@@ -143,7 +143,9 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
{
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
// Any non-conflict outcome here means nothing was left mid-merge for this dialog
// to resolve -- surface why instead of quietly closing with no explanation.
if (start.Status is "blocked" or "untracked_collision")
Error = start.ErrorMessage;
return false;
}
@@ -1207,6 +1207,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// out of Done. Surface that instead of silently looking like nothing happened.
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
// Nothing landed -- the merge was refused before touching the repo because the
// branch would have overwritten an untracked file in the target working directory.
else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
}
catch (Exception ex)
{
@@ -116,6 +116,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
// point of the gate.
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed");
break;
case "untracked_collision":
// Nothing landed -- the merge was refused before touching the repo. Show the
// real message (it names the colliding path and size); the locale string is
// only a fallback.
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.untrackedCollision");
break;
default:
ErrorMessage = Loc.T("vm.merge.unknownStatus", result.Status);
break;
+12 -9
View File
@@ -1059,15 +1059,18 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
"working tree, index and HEAD are untouched. status is 'clean' or 'conflict' (conflictFiles lists where git " +
"would stop); behind counts commits on targetBranch not yet on this branch, which flags a stale branch even " +
"when the preview is clean. If the list has a verify command configured, a clean preview is additionally " +
"built/tested in a scratch worktree (still without touching the real working tree) — verifyExitCode 0 means " +
"it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the tail of its output in " +
"verifyOutputTail; verifyExitCode stays null when no verify command is configured. isEmpty=true means the " +
"task's review range contributed nothing; check that flag rather than reading a small changedFileCount as " +
"empty. Throws if the task has neither an active worktree nor a handler commit range, or the list's working " +
"directory is missing from disk.")]
"working tree, index and HEAD are untouched. status is 'clean', 'conflict' (conflictFiles lists where git " +
"would stop), or 'untracked_collision' (conflictFiles lists a path the branch adds that already exists, " +
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
"small changedFileCount as empty. Throws if the task has neither an active worktree nor a handler commit " +
"range, or the list's working directory is missing from disk.")]
public async Task<MergePreviewToolDto> PreviewMerge(
string taskId,
[Description("Branch to preview against; defaults to the repo's current branch.")]
@@ -53,6 +53,7 @@ public sealed class TaskMergeService
public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted";
public const string StatusVerifyFailed = "verify_failed";
public const string StatusUntrackedCollision = "untracked_collision";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
@@ -60,6 +61,7 @@ public sealed class TaskMergeService
public const string PreviewClean = "clean";
public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable";
public const string PreviewUntrackedCollision = "untracked_collision";
// The verify command is a trusted, list-owner-configured build/test invocation (not
// per-request user input), so a generous fixed timeout is enough — no need for a
@@ -150,6 +152,82 @@ public sealed class TaskMergeService
return trimmed.Length <= maxChars ? trimmed : trimmed[^maxChars..];
}
/// <summary>
/// Guards against a merge silently overwriting a file that sits untracked in the target
/// working directory: git already refuses that itself while the path is still untracked at
/// merge time, but a path that only became trackable in between (e.g. an unrelated conflict
/// resolution's `git add -A` sweeping it up) loses that protection. Compares the paths
/// <paramref name="branchName"/> newly added since its merge base with <paramref name="targetRef"/>
/// against what's currently untracked in <paramref name="workingDir"/> — reusing
/// <see cref="GitService.GetCommittedFilesAsync"/> (run against the branch's own worktree, where
/// HEAD is the branch tip) and <see cref="GitService.GetStatusPorcelainAsync"/> rather than adding
/// new git plumbing. Returns null when there's nothing to flag.
/// </summary>
private async Task<MergeResult?> CheckUntrackedCollisionsAsync(
string workingDir, string targetRef, string worktreePath, string branchName, CancellationToken ct)
{
var mergeBase = await _git.MergeBaseAsync(workingDir, targetRef, branchName, ct);
if (mergeBase is null) return null;
List<string> addedByBranch;
try
{
addedByBranch = ParseAddedPaths(await _git.GetCommittedFilesAsync(worktreePath, mergeBase, ct));
}
catch { return null; } // worktree missing — nothing to compare, let the merge report its own outcome
if (addedByBranch.Count == 0) return null;
var untracked = ParseUntrackedPaths(await _git.GetStatusPorcelainAsync(workingDir, ct));
if (untracked.Count == 0) return null;
var untrackedSet = new HashSet<string>(untracked, StringComparer.OrdinalIgnoreCase);
var collisions = addedByBranch.Where(untrackedSet.Contains).ToList();
if (collisions.Count == 0) return null;
var details = collisions.Select(p => $"{p} ({DescribeSize(workingDir, p)})");
return new MergeResult(StatusUntrackedCollision, collisions,
"merge would overwrite untracked file(s) that exist in the target working directory: " +
string.Join(", ", details));
}
private static string DescribeSize(string workingDir, string relativePath)
{
try
{
var full = Path.Combine(workingDir, relativePath.Replace('/', Path.DirectorySeparatorChar));
return $"{new FileInfo(full).Length} bytes";
}
catch { return "size unknown"; }
}
// Only "A" (pure add) entries — a path the branch modifies but that already exists on the
// target's own history is a normal merge, not a collision with something untracked.
private static List<string> ParseAddedPaths(string nameStatus)
{
var result = new List<string>();
foreach (var raw in nameStatus.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var line = raw.TrimEnd('\r');
if (line.Length < 2 || line[0] != 'A') continue;
var tab = line.IndexOf('\t');
if (tab >= 0) result.Add(line[(tab + 1)..].Trim());
}
return result;
}
private static List<string> ParseUntrackedPaths(string porcelain)
{
var result = new List<string>();
foreach (var raw in porcelain.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("?? ", StringComparison.Ordinal))
result.Add(line[3..].Trim().Trim('"'));
}
return result;
}
private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct)
{
using (var ctx = _dbFactory.CreateDbContext())
@@ -205,6 +283,9 @@ public sealed class TaskMergeService
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, "HEAD", wt.Path, wt.BranchName, ct);
if (collision is not null) return collision;
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
if (exitCode != 0)
{
@@ -322,6 +403,14 @@ public sealed class TaskMergeService
if (stillConflicted.Count > 0)
return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");
// Closes the window between the original `git merge` (which already refused any
// untracked collision at that point) and this call: an untracked file matching one
// of the branch's own added paths could have appeared in the meantime, leaving the
// index and the working tree disagreeing about that path — committing then would
// either lose the local content or silently drop the branch's own added file.
var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, "HEAD", wt.Path, wt.BranchName, ct);
if (collision is not null) return collision;
// Stage exactly the resolved conflict paths — never `git add -A`, which would sweep
// untracked/unrelated changes left by other sessions into this merge commit (the
// target working dir is shared).
@@ -554,6 +643,12 @@ public sealed class TaskMergeService
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
// merge-tree (below) is a pure object-level check — it has no idea the working directory
// has an untracked file that would block (or, worse, silently be lost by) the real merge.
var collision = await CheckUntrackedCollisionsAsync(list.WorkingDir, target, wt.Path, wt.BranchName, ct);
if (collision is not null)
return new MergePreviewResult(PreviewUntrackedCollision, collision.ConflictFiles, 0);
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
if (!preview.Supported)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
@@ -349,6 +349,53 @@ public class TaskMergeServiceTests : IDisposable
Assert.Equal("merged", result.Status);
}
[Fact]
public async Task MergeAsync_BranchAddsPathUntrackedInTarget_RefusesAndLeavesLocalContentIntact()
{
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 task branch creates a brand new file at a path it has never seen tracked.
Directory.CreateDirectory(Path.Combine(wtCtx.WorktreePath, "docs"));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "docs", "PLUGIN.md"), "agent-written replacement\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
// The same path already exists, untracked, in the target working directory — never
// committed, not ignored — carrying content unrelated to the task branch.
Directory.CreateDirectory(Path.Combine(repo.RepoDir, "docs"));
var localPath = Path.Combine(repo.RepoDir, "docs", "PLUGIN.md");
const string localContent = "local plugin docs — must survive\n";
File.WriteAllText(localPath, localContent);
var (svc, proxy) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
Assert.Contains("docs/PLUGIN.md", result.ConflictFiles);
Assert.Contains("docs/PLUGIN.md", result.ErrorMessage ?? "");
Assert.Contains($"{System.Text.Encoding.UTF8.GetByteCount(localContent)} bytes", result.ErrorMessage ?? "");
// Nothing landed: no merge was attempted, the local file is untouched, worktree unaffected.
Assert.Equal(localContent, File.ReadAllText(localPath));
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
using var ctx = db.CreateContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, wt!.State);
}
[Fact]
public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging()
{
@@ -547,6 +594,66 @@ public class TaskMergeServiceTests : IDisposable
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
}
[Fact]
public async Task ContinueMergeAsync_PathBecomesUntrackedDuringResolution_RefusesBeforeAddAll()
{
// git's own preflight already refuses a merge whose incoming branch collides with a path
// that is untracked in the target *at merge-start* (verified against real git while
// investigating this guard). The only path left uncovered is one that becomes untracked
// again *during* conflict resolution, after the initial merge already staged it cleanly —
// this reproduces exactly that narrow window, right before `git add -A` would sweep it
// back in unnoticed.
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t-untracked", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
File.WriteAllText(Path.Combine(wtPath, "extra.md"), "agent content\n");
GitRepoFixture.RunGit(wtPath, "add", "-A");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "branch change + add extra.md");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t-untracked", repo.BaseCommit);
var (svc, _) = BuildService(db);
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
// extra.md landed clean already (git stages any non-conflicting file the moment the
// merge starts, even while README.md is left conflicted) -- confirm that, then unstage
// it and drop in different, untracked local content, reproducing "became untracked
// again mid-resolution".
Assert.Equal("A", GitRepoFixture.RunGit(repo.RepoDir, "diff", "--name-status", "--cached", "--", "extra.md").Trim()[..1]);
GitRepoFixture.RunGit(repo.RepoDir, "rm", "--cached", "extra.md");
const string localContent = "important local content that must survive\n";
File.WriteAllText(Path.Combine(repo.RepoDir, "extra.md"), localContent);
// Resolve the real (unrelated) conflict.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
Assert.Contains("extra.md", result.ConflictFiles);
Assert.Equal(localContent, File.ReadAllText(Path.Combine(repo.RepoDir, "extra.md")));
// Nothing committed; still mid-merge so abort/continue can still resolve it.
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
using var ctx = db.CreateContext();
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
Assert.Equal(WorktreeState.Active, wt.State);
}
[Fact]
public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
{
@@ -631,6 +738,33 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains("README.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_BranchAddsPathUntrackedInTarget_ReturnsUntrackedCollision()
{
// merge-tree (the real preview mechanism) never touches the working directory, so
// without this check the preview would say "clean" for a merge that can't actually land.
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
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, "PLUGIN.md"), "agent content\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "PLUGIN.md"), "local content\n");
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewUntrackedCollision, preview.Status);
Assert.Contains("PLUGIN.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
{