Skip to content

Commit cc0c9df

Browse files
authored
🤖 fix: detect squash-merged branches for SSH workspace deletion (#852)
SSH workspaces required force deletion after squash-merging PRs because the check used `git log --branches --not --remotes` which always shows commits for squash-merged branches (original commits differ from squash commit SHA). **Solution:** Added content-based comparison when unpushed commits are detected: 1. Fetch latest default branch from origin 2. Get files changed on branch since merge-base 3. Compare each file's content between HEAD and `origin/$DEFAULT` 4. If all files match → treat as merged (squash-merge case, allow deletion) 5. If files differ → genuinely unmerged work (require force) This allows clean deletion of squash-merged branches without forcing, while still protecting branches with real uncommitted work. --- _Generated with `mux`_
1 parent 7ea8d93 commit cc0c9df

File tree

2 files changed

+241
-9
lines changed

2 files changed

+241
-9
lines changed

src/node/runtime/SSHRuntime.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,23 +1087,59 @@ export class SSHRuntime implements Runtime {
10871087
# Get current branch for better error messaging
10881088
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
10891089
1090-
# Get default branch (try origin/HEAD, fallback to main, then master)
1091-
DEFAULT=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
1092-
if [ -z "$DEFAULT" ]; then
1093-
if git rev-parse --verify origin/main >/dev/null 2>&1; then
1094-
DEFAULT="main"
1095-
elif git rev-parse --verify origin/master >/dev/null 2>&1; then
1096-
DEFAULT="master"
1090+
# Get default branch (prefer main/master over origin/HEAD since origin/HEAD
1091+
# might point to a feature branch in some setups)
1092+
if git rev-parse --verify origin/main >/dev/null 2>&1; then
1093+
DEFAULT="main"
1094+
elif git rev-parse --verify origin/master >/dev/null 2>&1; then
1095+
DEFAULT="master"
1096+
else
1097+
# Fallback to origin/HEAD if main/master don't exist
1098+
DEFAULT=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
1099+
fi
1100+
1101+
# Check for squash-merge: if all changed files match origin/$DEFAULT, content is merged
1102+
if [ -n "$DEFAULT" ]; then
1103+
# Fetch latest to ensure we have current remote state
1104+
git fetch origin "$DEFAULT" --quiet 2>/dev/null || true
1105+
1106+
# Get merge-base between current branch and default
1107+
MERGE_BASE=$(git merge-base "origin/$DEFAULT" HEAD 2>/dev/null)
1108+
if [ -n "$MERGE_BASE" ]; then
1109+
# Get files changed on this branch since fork point
1110+
CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" HEAD 2>/dev/null)
1111+
1112+
if [ -n "$CHANGED_FILES" ]; then
1113+
# Check if all changed files match what's in origin/$DEFAULT
1114+
ALL_MERGED=true
1115+
while IFS= read -r f; do
1116+
# Compare file content between HEAD and origin/$DEFAULT
1117+
# If file doesn't exist in one but exists in other, they differ
1118+
if ! git diff --quiet "HEAD:$f" "origin/$DEFAULT:$f" 2>/dev/null; then
1119+
ALL_MERGED=false
1120+
break
1121+
fi
1122+
done <<< "$CHANGED_FILES"
1123+
1124+
if $ALL_MERGED; then
1125+
# All changes are in default branch - safe to delete (squash-merge case)
1126+
exit 0
1127+
fi
1128+
else
1129+
# No changed files means nothing to merge - safe to delete
1130+
exit 0
1131+
fi
10971132
fi
10981133
fi
10991134
1100-
# If we have both branch and default, use show-branch for better output
1135+
# If we get here, there are real unpushed changes
1136+
# Show helpful output for debugging
11011137
if [ -n "$BRANCH" ] && [ -n "$DEFAULT" ] && git show-branch "$BRANCH" "origin/$DEFAULT" >/dev/null 2>&1; then
11021138
echo "Branch status compared to origin/$DEFAULT:" >&2
11031139
echo "" >&2
11041140
git show-branch "$BRANCH" "origin/$DEFAULT" 2>&1 | head -20 >&2
11051141
echo "" >&2
1106-
echo "Note: If your PR was squash-merged, these commits are already in origin/$DEFAULT and safe to delete." >&2
1142+
echo "Note: Branch has changes not yet in origin/$DEFAULT." >&2
11071143
else
11081144
# Fallback to just showing the commit list
11091145
echo "$unpushed" | head -10 >&2

tests/ipcMain/removeWorkspace.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/unpushed|changes/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

Comments
 (0)