diff --git a/src/components/ForceDeleteModal.tsx b/src/components/ForceDeleteModal.tsx index b183dace6b..8b87b0e34a 100644 --- a/src/components/ForceDeleteModal.tsx +++ b/src/components/ForceDeleteModal.tsx @@ -60,8 +60,11 @@ export const ForceDeleteModal: React.FC = ({ This action cannot be undone - Force deleting will permanently remove the workspace and may discard uncommitted work or - lose data. + Force deleting will permanently remove the workspace and{" "} + {error.includes("unpushed commits:") + ? "discard the unpushed commits shown above" + : "may discard uncommitted work or lose data"} + . This action cannot be undone. diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index 1403a40e55..6ee0ae9321 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -984,7 +984,11 @@ export class SSHRuntime implements Runtime { cd ${shescape.quote(deletedPath)} || exit 1 git diff --quiet --exit-code && git diff --quiet --cached --exit-code || exit 1 if git remote | grep -q .; then - git log --branches --not --remotes --oneline | head -1 | grep -q . && exit 2 + unpushed=$(git log --branches --not --remotes --oneline) + if [ -n "$unpushed" ]; then + echo "$unpushed" | head -10 >&2 + exit 2 + fi fi exit 0 `; @@ -1012,9 +1016,28 @@ export class SSHRuntime implements Runtime { } if (checkExitCode === 2) { + // Read stderr which contains the unpushed commits output + const stderrReader = checkStream.stderr.getReader(); + const decoder = new TextDecoder(); + let stderr = ""; + try { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + } finally { + stderrReader.releaseLock(); + } + + const commitList = stderr.trim(); + const errorMsg = commitList + ? `Workspace contains unpushed commits:\n\n${commitList}` + : `Workspace contains unpushed commits. Use force flag to delete anyway.`; + return { success: false, - error: `Workspace contains unpushed commits. Use force flag to delete anyway.`, + error: errorMsg, }; } diff --git a/tests/ipcMain/removeWorkspace.test.ts b/tests/ipcMain/removeWorkspace.test.ts index 38af81f93d..aa395b1079 100644 --- a/tests/ipcMain/removeWorkspace.test.ts +++ b/tests/ipcMain/removeWorkspace.test.ts @@ -614,5 +614,67 @@ describeIntegration("Workspace deletion integration tests", () => { }, TEST_TIMEOUT_SSH_MS ); + + test.concurrent( + "should include commit list in error for unpushed refs", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + const branchName = generateBranchName("delete-unpushed-details"); + const runtimeConfig = getRuntimeConfig(branchName); + const { workspaceId } = await createWorkspaceWithInit( + env, + tempGitRepo, + branchName, + runtimeConfig, + true, // waitForInit + true // isSSH + ); + + // Configure git for committing (SSH environment needs this) + await executeBash(env, workspaceId, 'git config user.email "test@example.com"'); + await executeBash(env, workspaceId, 'git config user.name "Test User"'); + + // Add a fake remote (needed for unpushed check to work) + await executeBash( + env, + workspaceId, + "git remote add origin https://github.com/fake/repo.git" + ); + + // Create multiple commits with descriptive messages + await executeBash(env, workspaceId, 'echo "1" > file1.txt'); + await executeBash(env, workspaceId, "git add file1.txt"); + await executeBash(env, workspaceId, 'git commit -m "First commit"'); + + await executeBash(env, workspaceId, 'echo "2" > file2.txt'); + await executeBash(env, workspaceId, "git add file2.txt"); + await executeBash(env, workspaceId, 'git commit -m "Second commit"'); + + // Attempt to delete + const deleteResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_REMOVE, + workspaceId + ); + + // Should fail with error containing commit details + expect(deleteResult.success).toBe(false); + expect(deleteResult.error).toContain("unpushed commits:"); + expect(deleteResult.error).toContain("First commit"); + expect(deleteResult.error).toContain("Second commit"); + + // Cleanup: force delete for cleanup + await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, { + force: true, + }); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + TEST_TIMEOUT_SSH_MS + ); }); });