fix(merger,dashboard): handle empty-squash commitSha to keep card and Changes tab in sync

When a branch contained commits already on main (duplicate cherry-picks),
the merger's local squash collapsed to an empty commit. The merger then
recorded that empty commit's SHA on mergeDetails.commitSha. The actual
content landed later on main as a different SHA via PR merge, but the
task kept pointing at the orphaned empty commit.

Symptom: TaskCard showed "N files changed" (falling back to
task.modifiedFiles), but the Changes tab in the modal showed nothing
because the API hit `git diff sha^..sha` on the empty commit and
returned no files.

Two fixes:

1. merger.ts: detect empty squash commits and skip storing commitSha,
   logging clearly. recoverInterruptedMergingTasks → findLandedTaskCommit
   already exists to backfill the right SHA when the real commit lands;
   a missing commitSha is a known fallback path the UI already handles.

2. TaskChangesTab.tsx: when the API returns no files for a done task,
   fall back to task.modifiedFiles (paths only, no patches) with a clear
   note. Mirrors the existing 3-tier fallback in TaskCard.tsx:1090-1124
   so card and modal always agree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 13:31:34 -07:00
parent c3fe4fd5b6
commit 85ba1071bd
3 changed files with 81 additions and 4 deletions

View File

@@ -13,6 +13,15 @@ interface TaskChangesTabProps {
projectId?: string;
column?: Column;
mergeDetails?: MergeDetails;
/**
* Files modified by the task during execution, captured from the worktree.
* Used as a last-resort fallback when the recorded `mergeDetails.commitSha`
* resolves to an empty git commit (which can happen when the merger stores
* a per-branch SHA that gets collapsed into a different squash on main).
* Without this, the tab would show "no changes" while the card shows N —
* matches TaskCard.tsx:1090-1124's fallback ladder.
*/
modifiedFiles?: string[];
}
function getStatusLabel(status: "added" | "modified" | "deleted" | "unknown"): string {
@@ -51,7 +60,7 @@ interface NormalizedFile {
* changes. This prevents inflated file counts that don't match the card-level
* display.
*/
export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails }: TaskChangesTabProps) {
export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetails, modifiedFiles }: TaskChangesTabProps) {
const [files, setFiles] = useState<NormalizedFile[]>([]);
const [stats, setStats] = useState<{ filesChanged: number; additions: number; deletions: number }>({ filesChanged: 0, additions: 0, deletions: 0 });
const [loading, setLoading] = useState(true);
@@ -192,6 +201,57 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
}
if (files.length === 0) {
// Done task with a commit SHA but the diff came back empty — almost
// always means the recorded SHA points to an empty commit (merger
// stored a per-branch SHA that became no-op after the squash collapsed
// onto main). Fall back to the executor-captured modifiedFiles so the
// tab agrees with the card. Patches are unavailable in this path.
if (isDone && modifiedFiles && modifiedFiles.length > 0) {
return (
<div className="detail-section task-changes-tab">
{isDone && mergeDetails && (
<div className="commit-diff-meta">
{mergeDetails.commitSha && (
<div className="commit-diff-sha">
<GitCommit size={14} />
<code>{mergeDetails.commitSha.slice(0, 7)}</code>
</div>
)}
{mergeDetails.mergedAt && (
<div className="commit-diff-timestamp">
Merged {new Date(mergeDetails.mergedAt).toLocaleString()}
</div>
)}
</div>
)}
<div className="task-changes-state task-changes-state--empty">
<FileCode size={24} />
<p>{modifiedFiles.length} file{modifiedFiles.length === 1 ? "" : "s"} modified during execution.</p>
<span className="task-changes-state-hint">
The recorded merge commit has no diff (likely collapsed into a squash on main). Showing file paths only patches unavailable.
</span>
</div>
<div className="changes-file-list task-changes-file-list--compact">
{modifiedFiles.map((path) => (
<div key={path} className="changes-file-item">
<div className="changes-file-header">
<span
className="changes-file-status changes-file-status--unknown"
title="status unknown"
>
{getStatusLabel("unknown")}
</span>
<span className="changes-file-path" title={path}>
{truncateMiddle(path)}
</span>
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="detail-section">
<div className="task-changes-state task-changes-state--empty">

View File

@@ -1355,7 +1355,7 @@ export function TaskDetailModal({
)}
</div>
) : activeTab === "changes" ? (
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} column={task.column} mergeDetails={task.mergeDetails} />
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} column={task.column} mergeDetails={task.mergeDetails} modifiedFiles={task.modifiedFiles} />
) : activeTab === "comments" ? (
<TaskComments task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
) : activeTab === "documents" ? (