Skip to content

Commit a3da495

Browse files
committed
test: add integration tests for squash-merge detection
Added two new SSH-specific tests: - 'should allow deletion of squash-merged branches without force flag' Simulates a squash-merge scenario where branch content matches main but commits differ, verifies deletion succeeds without force. - 'should block deletion when branch has genuinely unmerged content' Verifies branches with content not in main still require force.
1 parent 552d94c commit a3da495

File tree

1 file changed

+214
-0
lines changed

1 file changed

+214
-0
lines changed

tests/ipcMain/removeWorkspace.test.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,5 +799,219 @@ describeIntegration("Workspace deletion integration tests", () => {
799799
},
800800
TEST_TIMEOUT_SSH_MS
801801
);
802+
803+
test.concurrent(
804+
"should allow deletion of squash-merged branches without force flag",
805+
async () => {
806+
const env = await createTestEnvironment();
807+
const tempGitRepo = await createTempGitRepo();
808+
809+
try {
810+
const branchName = generateBranchName("squash-merge-test");
811+
const runtimeConfig = getRuntimeConfig(branchName);
812+
const { workspaceId } = await createWorkspaceWithInit(
813+
env,
814+
tempGitRepo,
815+
branchName,
816+
runtimeConfig,
817+
true, // waitForInit
818+
true // isSSH
819+
);
820+
821+
// Configure git for committing
822+
await executeBash(env, workspaceId, 'git config user.email "test@example.com"');
823+
await executeBash(env, workspaceId, 'git config user.name "Test User"');
824+
825+
// Get the current workspace path (inside SSH container)
826+
const pwdResult = await executeBash(env, workspaceId, "pwd");
827+
const workspacePath = pwdResult.output.trim();
828+
829+
// Create a bare repo inside the SSH container to act as "origin"
830+
// This avoids issues with host paths not being accessible in container
831+
const originPath = `${workspacePath}/../.test-origin-${branchName}`;
832+
await executeBash(env, workspaceId, `git clone --bare . "${originPath}"`);
833+
834+
// Point origin to the bare repo
835+
await executeBash(env, workspaceId, `git remote set-url origin "${originPath}"`);
836+
837+
// Create feature commits on the branch
838+
await executeBash(env, workspaceId, 'echo "feature1" > feature.txt');
839+
await executeBash(env, workspaceId, "git add feature.txt");
840+
await executeBash(env, workspaceId, 'git commit -m "Feature commit 1"');
841+
842+
await executeBash(env, workspaceId, 'echo "feature2" >> feature.txt');
843+
await executeBash(env, workspaceId, "git add feature.txt");
844+
await executeBash(env, workspaceId, 'git commit -m "Feature commit 2"');
845+
846+
// Get the feature branch's final file content
847+
const featureContent = await executeBash(env, workspaceId, "cat feature.txt");
848+
849+
// Simulate squash-merge: create a temp worktree, add the squash commit to main, push
850+
// We need to work around bare repo limitations by using a temp checkout
851+
const tempCheckoutPath = `${workspacePath}/../.test-temp-checkout-${branchName}`;
852+
await executeBash(
853+
env,
854+
workspaceId,
855+
`git clone "${originPath}" "${tempCheckoutPath}" && ` +
856+
`cd "${tempCheckoutPath}" && ` +
857+
`git config user.email "test@example.com" && ` +
858+
`git config user.name "Test User" && ` +
859+
// Checkout main (or master, depending on git version)
860+
`(git checkout main 2>/dev/null || git checkout master) && ` +
861+
// Create squash commit with same content
862+
`printf '%s' '${featureContent.output.trim().replace(/'/g, "'\\''")}' > feature.txt && ` +
863+
`git add feature.txt && ` +
864+
`git commit -m "Squash: Feature commits" && ` +
865+
`git push origin HEAD`
866+
);
867+
868+
// Cleanup temp checkout
869+
await executeBash(env, workspaceId, `rm -rf "${tempCheckoutPath}"`);
870+
871+
// Fetch the updated origin in the workspace
872+
await executeBash(env, workspaceId, "git fetch origin");
873+
874+
// Verify we have unpushed commits (branch commits are not ancestors of origin/main)
875+
const logResult = await executeBash(
876+
env,
877+
workspaceId,
878+
"git log --branches --not --remotes --oneline"
879+
);
880+
// Should show commits since our branch commits != squash commit SHA
881+
expect(logResult.output.trim()).not.toBe("");
882+
883+
// Now attempt deletion without force - should succeed because content matches
884+
const deleteResult = await env.mockIpcRenderer.invoke(
885+
IPC_CHANNELS.WORKSPACE_REMOVE,
886+
workspaceId
887+
);
888+
889+
// Should succeed - squash-merge detection should recognize content is in main
890+
expect(deleteResult.success).toBe(true);
891+
892+
// Cleanup the bare repo we created
893+
// Note: This runs after workspace is deleted, may fail if path is gone
894+
try {
895+
using cleanupProc = execAsync(`rm -rf "${originPath}"`);
896+
await cleanupProc.result;
897+
} catch {
898+
// Ignore cleanup errors
899+
}
900+
901+
// Verify workspace was removed from config
902+
const config = env.config.loadConfigOrDefault();
903+
const project = config.projects.get(tempGitRepo);
904+
if (project) {
905+
const stillInConfig = project.workspaces.some((w) => w.id === workspaceId);
906+
expect(stillInConfig).toBe(false);
907+
}
908+
} finally {
909+
await cleanupTestEnvironment(env);
910+
await cleanupTempGitRepo(tempGitRepo);
911+
}
912+
},
913+
TEST_TIMEOUT_SSH_MS
914+
);
915+
916+
test.concurrent(
917+
"should block deletion when branch has genuinely unmerged content",
918+
async () => {
919+
const env = await createTestEnvironment();
920+
const tempGitRepo = await createTempGitRepo();
921+
922+
try {
923+
const branchName = generateBranchName("unmerged-content-test");
924+
const runtimeConfig = getRuntimeConfig(branchName);
925+
const { workspaceId } = await createWorkspaceWithInit(
926+
env,
927+
tempGitRepo,
928+
branchName,
929+
runtimeConfig,
930+
true, // waitForInit
931+
true // isSSH
932+
);
933+
934+
// Configure git for committing
935+
await executeBash(env, workspaceId, 'git config user.email "test@example.com"');
936+
await executeBash(env, workspaceId, 'git config user.name "Test User"');
937+
938+
// Get the current workspace path (inside SSH container)
939+
const pwdResult = await executeBash(env, workspaceId, "pwd");
940+
const workspacePath = pwdResult.output.trim();
941+
942+
// Create a bare repo inside the SSH container to act as "origin"
943+
const originPath = `${workspacePath}/../.test-origin-${branchName}`;
944+
await executeBash(env, workspaceId, `git clone --bare . "${originPath}"`);
945+
946+
// Point origin to the bare repo
947+
await executeBash(env, workspaceId, `git remote set-url origin "${originPath}"`);
948+
949+
// Create feature commits with unique content (not in origin)
950+
await executeBash(env, workspaceId, 'echo "unique-unmerged-content" > unique.txt');
951+
await executeBash(env, workspaceId, "git add unique.txt");
952+
await executeBash(env, workspaceId, 'git commit -m "Unique commit"');
953+
954+
// Fetch origin (main doesn't have our content - we didn't push)
955+
await executeBash(env, workspaceId, "git fetch origin");
956+
957+
// Debug: verify the setup is correct before attempting delete
958+
const debugBranches = await executeBash(env, workspaceId, "git branch -a");
959+
const debugLog = await executeBash(
960+
env,
961+
workspaceId,
962+
"git log --oneline --all | head -10"
963+
);
964+
const debugRemote = await executeBash(env, workspaceId, "git remote -v");
965+
console.log(
966+
"DEBUG unmerged test - branches:",
967+
debugBranches.output,
968+
"log:",
969+
debugLog.output,
970+
"remote:",
971+
debugRemote.output
972+
);
973+
974+
// Attempt deletion without force - should fail because content differs
975+
const deleteResult = await env.mockIpcRenderer.invoke(
976+
IPC_CHANNELS.WORKSPACE_REMOVE,
977+
workspaceId
978+
);
979+
980+
// Should fail - genuinely unmerged content
981+
if (deleteResult.success) {
982+
// Provide debug info if test would fail
983+
throw new Error(
984+
`Expected deletion to fail but it succeeded.\n` +
985+
`Branches: ${debugBranches.output}\n` +
986+
`Log: ${debugLog.output}\n` +
987+
`Remote: ${debugRemote.output}\n` +
988+
`Result: ${JSON.stringify(deleteResult)}`
989+
);
990+
}
991+
expect(deleteResult.error).toMatch(/unpushed|changes/i);
992+
993+
// Verify workspace still exists
994+
const stillExists = await workspaceExists(env, workspaceId);
995+
expect(stillExists).toBe(true);
996+
997+
// Cleanup: force delete
998+
await env.mockIpcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, {
999+
force: true,
1000+
});
1001+
1002+
// Cleanup the bare repo
1003+
try {
1004+
using cleanupProc = execAsync(`rm -rf "${originPath}"`);
1005+
await cleanupProc.result;
1006+
} catch {
1007+
// Ignore cleanup errors
1008+
}
1009+
} finally {
1010+
await cleanupTestEnvironment(env);
1011+
await cleanupTempGitRepo(tempGitRepo);
1012+
}
1013+
},
1014+
TEST_TIMEOUT_SSH_MS
1015+
);
8021016
});
8031017
});

0 commit comments

Comments
 (0)