@@ -799,5 +799,193 @@ 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+ // Attempt deletion without force - should fail because content differs
958+ const deleteResult = await env . mockIpcRenderer . invoke (
959+ IPC_CHANNELS . WORKSPACE_REMOVE ,
960+ workspaceId
961+ ) ;
962+
963+ // Should fail - genuinely unmerged content
964+ expect ( deleteResult . success ) . toBe ( false ) ;
965+ expect ( deleteResult . error ) . toMatch ( / u n p u s h e d | c h a n g e s / i) ;
966+
967+ // Verify workspace still exists
968+ const stillExists = await workspaceExists ( env , workspaceId ) ;
969+ expect ( stillExists ) . toBe ( true ) ;
970+
971+ // Cleanup: force delete
972+ await env . mockIpcRenderer . invoke ( IPC_CHANNELS . WORKSPACE_REMOVE , workspaceId , {
973+ force : true ,
974+ } ) ;
975+
976+ // Cleanup the bare repo
977+ try {
978+ using cleanupProc = execAsync ( `rm -rf "${ originPath } "` ) ;
979+ await cleanupProc . result ;
980+ } catch {
981+ // Ignore cleanup errors
982+ }
983+ } finally {
984+ await cleanupTestEnvironment ( env ) ;
985+ await cleanupTempGitRepo ( tempGitRepo ) ;
986+ }
987+ } ,
988+ TEST_TIMEOUT_SSH_MS
989+ ) ;
802990 } ) ;
803991} ) ;
0 commit comments