@@ -799,5 +799,227 @@ 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
866+ `printf '%s' '${ 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+ // Debug: verify the setup is correct before attempting delete
966+ const debugBranches = await executeBash ( env , workspaceId , "git branch -a" ) ;
967+ const debugLog = await executeBash (
968+ env ,
969+ workspaceId ,
970+ "git log --oneline --all | head -10"
971+ ) ;
972+ const debugRemote = await executeBash ( env , workspaceId , "git remote -v" ) ;
973+ console . log (
974+ "DEBUG unmerged test - branches:" ,
975+ debugBranches . output ,
976+ "log:" ,
977+ debugLog . output ,
978+ "remote:" ,
979+ debugRemote . output
980+ ) ;
981+
982+ // Attempt deletion without force - should fail because content differs
983+ const deleteResult = await env . mockIpcRenderer . invoke (
984+ IPC_CHANNELS . WORKSPACE_REMOVE ,
985+ workspaceId
986+ ) ;
987+
988+ // Should fail - genuinely unmerged content
989+ if ( deleteResult . success ) {
990+ // Provide debug info if test would fail
991+ throw new Error (
992+ `Expected deletion to fail but it succeeded.\n` +
993+ `Branches: ${ debugBranches . output } \n` +
994+ `Log: ${ debugLog . output } \n` +
995+ `Remote: ${ debugRemote . output } \n` +
996+ `Result: ${ JSON . stringify ( deleteResult ) } `
997+ ) ;
998+ }
999+ expect ( deleteResult . error ) . toMatch ( / u n p u s h e d | c h a n g e s / i) ;
1000+
1001+ // Verify workspace still exists
1002+ const stillExists = await workspaceExists ( env , workspaceId ) ;
1003+ expect ( stillExists ) . toBe ( true ) ;
1004+
1005+ // Cleanup: force delete
1006+ await env . mockIpcRenderer . invoke ( IPC_CHANNELS . WORKSPACE_REMOVE , workspaceId , {
1007+ force : true ,
1008+ } ) ;
1009+
1010+ // Cleanup the bare repo
1011+ try {
1012+ using cleanupProc = execAsync ( `rm -rf "${ originPath } "` ) ;
1013+ await cleanupProc . result ;
1014+ } catch {
1015+ // Ignore cleanup errors
1016+ }
1017+ } finally {
1018+ await cleanupTestEnvironment ( env ) ;
1019+ await cleanupTempGitRepo ( tempGitRepo ) ;
1020+ }
1021+ } ,
1022+ TEST_TIMEOUT_SSH_MS
1023+ ) ;
8021024 } ) ;
8031025} ) ;
0 commit comments