@@ -799,5 +799,201 @@ 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 (add if doesn't exist, set-url if it does)
835+ await executeBash (
836+ env ,
837+ workspaceId ,
838+ `git remote get-url origin >/dev/null 2>&1 && git remote set-url origin "${ originPath } " || git remote add origin "${ originPath } "`
839+ ) ;
840+
841+ // Create feature commits on the branch
842+ await executeBash ( env , workspaceId , 'echo "feature1" > feature.txt' ) ;
843+ await executeBash ( env , workspaceId , "git add feature.txt" ) ;
844+ await executeBash ( env , workspaceId , 'git commit -m "Feature commit 1"' ) ;
845+
846+ await executeBash ( env , workspaceId , 'echo "feature2" >> feature.txt' ) ;
847+ await executeBash ( env , workspaceId , "git add feature.txt" ) ;
848+ await executeBash ( env , workspaceId , 'git commit -m "Feature commit 2"' ) ;
849+
850+ // Get the feature branch's final file content
851+ const featureContent = await executeBash ( env , workspaceId , "cat feature.txt" ) ;
852+
853+ // Simulate squash-merge: create a temp worktree, add the squash commit to main, push
854+ // We need to work around bare repo limitations by using a temp checkout
855+ const tempCheckoutPath = `${ workspacePath } /../.test-temp-checkout-${ branchName } ` ;
856+ await executeBash (
857+ env ,
858+ workspaceId ,
859+ `git clone "${ originPath } " "${ tempCheckoutPath } " && ` +
860+ `cd "${ tempCheckoutPath } " && ` +
861+ `git config user.email "test@example.com" && ` +
862+ `git config user.name "Test User" && ` +
863+ // Checkout main (or master, depending on git version)
864+ `(git checkout main 2>/dev/null || git checkout master) && ` +
865+ // Create squash commit with same content (use printf '%s\n' to match echo's newline)
866+ `printf '%s\\n' '${ featureContent . output . trim ( ) . replace ( / ' / g, "'\\''" ) } ' > feature.txt && ` +
867+ `git add feature.txt && ` +
868+ `git commit -m "Squash: Feature commits" && ` +
869+ `git push origin HEAD`
870+ ) ;
871+
872+ // Cleanup temp checkout
873+ await executeBash ( env , workspaceId , `rm -rf "${ tempCheckoutPath } "` ) ;
874+
875+ // Fetch the updated origin in the workspace
876+ await executeBash ( env , workspaceId , "git fetch origin" ) ;
877+
878+ // Verify we have unpushed commits (branch commits are not ancestors of origin/main)
879+ const logResult = await executeBash (
880+ env ,
881+ workspaceId ,
882+ "git log --branches --not --remotes --oneline"
883+ ) ;
884+ // Should show commits since our branch commits != squash commit SHA
885+ expect ( logResult . output . trim ( ) ) . not . toBe ( "" ) ;
886+
887+ // Now attempt deletion without force - should succeed because content matches
888+ const deleteResult = await env . mockIpcRenderer . invoke (
889+ IPC_CHANNELS . WORKSPACE_REMOVE ,
890+ workspaceId
891+ ) ;
892+
893+ // Should succeed - squash-merge detection should recognize content is in main
894+ expect ( deleteResult . success ) . toBe ( true ) ;
895+
896+ // Cleanup the bare repo we created
897+ // Note: This runs after workspace is deleted, may fail if path is gone
898+ try {
899+ using cleanupProc = execAsync ( `rm -rf "${ originPath } "` ) ;
900+ await cleanupProc . result ;
901+ } catch {
902+ // Ignore cleanup errors
903+ }
904+
905+ // Verify workspace was removed from config
906+ const config = env . config . loadConfigOrDefault ( ) ;
907+ const project = config . projects . get ( tempGitRepo ) ;
908+ if ( project ) {
909+ const stillInConfig = project . workspaces . some ( ( w ) => w . id === workspaceId ) ;
910+ expect ( stillInConfig ) . toBe ( false ) ;
911+ }
912+ } finally {
913+ await cleanupTestEnvironment ( env ) ;
914+ await cleanupTempGitRepo ( tempGitRepo ) ;
915+ }
916+ } ,
917+ TEST_TIMEOUT_SSH_MS
918+ ) ;
919+
920+ test . concurrent (
921+ "should block deletion when branch has genuinely unmerged content" ,
922+ async ( ) => {
923+ const env = await createTestEnvironment ( ) ;
924+ const tempGitRepo = await createTempGitRepo ( ) ;
925+
926+ try {
927+ const branchName = generateBranchName ( "unmerged-content-test" ) ;
928+ const runtimeConfig = getRuntimeConfig ( branchName ) ;
929+ const { workspaceId } = await createWorkspaceWithInit (
930+ env ,
931+ tempGitRepo ,
932+ branchName ,
933+ runtimeConfig ,
934+ true , // waitForInit
935+ true // isSSH
936+ ) ;
937+
938+ // Configure git for committing
939+ await executeBash ( env , workspaceId , 'git config user.email "test@example.com"' ) ;
940+ await executeBash ( env , workspaceId , 'git config user.name "Test User"' ) ;
941+
942+ // Get the current workspace path (inside SSH container)
943+ const pwdResult = await executeBash ( env , workspaceId , "pwd" ) ;
944+ const workspacePath = pwdResult . output . trim ( ) ;
945+
946+ // Create a bare repo inside the SSH container to act as "origin"
947+ const originPath = `${ workspacePath } /../.test-origin-${ branchName } ` ;
948+ await executeBash ( env , workspaceId , `git clone --bare . "${ originPath } "` ) ;
949+
950+ // Point origin to the bare repo (add if doesn't exist, set-url if it does)
951+ await executeBash (
952+ env ,
953+ workspaceId ,
954+ `git remote get-url origin >/dev/null 2>&1 && git remote set-url origin "${ originPath } " || git remote add origin "${ originPath } "`
955+ ) ;
956+
957+ // Create feature commits with unique content (not in origin)
958+ await executeBash ( env , workspaceId , 'echo "unique-unmerged-content" > unique.txt' ) ;
959+ await executeBash ( env , workspaceId , "git add unique.txt" ) ;
960+ await executeBash ( env , workspaceId , 'git commit -m "Unique commit"' ) ;
961+
962+ // Fetch origin (main doesn't have our content - we didn't push)
963+ await executeBash ( env , workspaceId , "git fetch origin" ) ;
964+
965+ // Attempt deletion without force - should fail because content differs
966+ const deleteResult = await env . mockIpcRenderer . invoke (
967+ IPC_CHANNELS . WORKSPACE_REMOVE ,
968+ workspaceId
969+ ) ;
970+
971+ // Should fail - genuinely unmerged content
972+ expect ( deleteResult . success ) . toBe ( false ) ;
973+ expect ( deleteResult . error ) . toMatch ( / u n p u s h e d | c h a n g e s / i) ;
974+
975+ // Verify workspace still exists
976+ const stillExists = await workspaceExists ( env , workspaceId ) ;
977+ expect ( stillExists ) . toBe ( true ) ;
978+
979+ // Cleanup: force delete
980+ await env . mockIpcRenderer . invoke ( IPC_CHANNELS . WORKSPACE_REMOVE , workspaceId , {
981+ force : true ,
982+ } ) ;
983+
984+ // Cleanup the bare repo
985+ try {
986+ using cleanupProc = execAsync ( `rm -rf "${ originPath } "` ) ;
987+ await cleanupProc . result ;
988+ } catch {
989+ // Ignore cleanup errors
990+ }
991+ } finally {
992+ await cleanupTestEnvironment ( env ) ;
993+ await cleanupTempGitRepo ( tempGitRepo ) ;
994+ }
995+ } ,
996+ TEST_TIMEOUT_SSH_MS
997+ ) ;
802998 } ) ;
803999} ) ;
0 commit comments