@@ -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 ( / u n p u s h e d | c h a n g e s / 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