Skip to content

Commit 10c7f43

Browse files
authored
Block deletion of git-backed default branch namespaces (#1945)
* Block default branch deletion * Fix * Fix
1 parent 5e6a05f commit 10c7f43

File tree

5 files changed

+140
-6
lines changed

5 files changed

+140
-6
lines changed

datajunction-server/datajunction_server/alembic/versions/2026_03_29_0000-a9b8c7d6e5f4_add_spark_hints_to_dimensionlink.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@
1818

1919
def upgrade():
2020
op.execute(
21-
"CREATE TYPE sparkjoinstrategy AS ENUM "
22-
"('broadcast', 'merge', 'shuffle_hash', 'shuffle_replicate_nl')",
21+
"""
22+
DO $$ BEGIN
23+
CREATE TYPE sparkjoinstrategy AS ENUM
24+
('broadcast', 'merge', 'shuffle_hash', 'shuffle_replicate_nl');
25+
EXCEPTION
26+
WHEN duplicate_object THEN null;
27+
END $$;
28+
""",
2329
)
2430
op.add_column(
2531
"dimensionlink",

datajunction-server/datajunction_server/api/namespaces.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
)
3737
from datajunction_server.internal.namespaces import (
3838
create_namespace,
39+
get_git_info_for_namespace,
3940
get_nodes_in_namespace,
4041
get_nodes_in_namespace_detailed,
4142
get_project_config,
@@ -264,6 +265,14 @@ async def deactivate_a_namespace(
264265
message=f"Namespace `{namespace}` is already deactivated.",
265266
)
266267

268+
git_info = await get_git_info_for_namespace(session, namespace)
269+
if git_info and git_info.get("is_default_branch") and git_info.get("repo"):
270+
raise DJInvalidInputException(
271+
message=f"Cannot delete namespace `{namespace}`: it is the default branch "
272+
f"of a git-backed namespace ({git_info['repo']}). "
273+
"Only non-default branch namespaces can be deleted.",
274+
)
275+
267276
# If there are no active nodes in the namespace, we can safely deactivate this namespace
268277
node_list = await NodeNamespace.list_nodes(session, namespace)
269278
node_names = [node.name for node in node_list]
@@ -422,6 +431,14 @@ async def hard_delete_node_namespace(
422431
access_checker.add_namespace(namespace, ResourceAction.DELETE)
423432
await access_checker.check(on_denied=AccessDenialMode.RAISE)
424433

434+
git_info = await get_git_info_for_namespace(session, namespace)
435+
if git_info and git_info.get("is_default_branch") and git_info.get("repo"):
436+
raise DJInvalidInputException(
437+
message=f"Cannot delete namespace `{namespace}`: it is the default branch "
438+
f"of a git-backed namespace ({git_info['repo']}). "
439+
"Only non-default branch namespaces can be deleted.",
440+
)
441+
425442
impacts = await hard_delete_namespace(
426443
session=session,
427444
namespace=namespace,

datajunction-server/datajunction_server/internal/namespaces.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,8 @@ def resolve_git_info_from_map(
292292
"default_branch": default_branch,
293293
"path": config_ns.git_path,
294294
"is_default_branch": (
295-
branch == default_branch if branch and default_branch else True
295+
branch is None # root namespace — no branch means it IS the default
296+
or (default_branch is not None and branch == default_branch)
296297
),
297298
"parent_namespace": branch_ns.parent_namespace if branch_ns else None,
298299
"git_only": config_ns.git_only,

datajunction-server/tests/api/namespaces_test.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2557,3 +2557,113 @@ async def test_list_namespace_branches_excludes_deactivated(
25572557
namespaces = [b["namespace"] for b in response.json()]
25582558
assert active_ns in namespaces
25592559
assert deact_ns not in namespaces
2560+
2561+
2562+
@pytest.mark.asyncio
2563+
async def test_deactivate_git_default_branch_namespace_blocked(
2564+
module__client_with_all_examples: AsyncClient,
2565+
) -> None:
2566+
"""
2567+
Deleting (soft or hard) a git-backed namespace whose git_branch matches
2568+
its default_branch must be rejected, regardless of cascade setting.
2569+
"""
2570+
root = "protected.root.deact"
2571+
branch_ns = "protected.root.deact.main"
2572+
2573+
# Set up a git-root namespace with a default branch
2574+
await module__client_with_all_examples.post(f"/namespaces/{root}/")
2575+
await module__client_with_all_examples.patch(
2576+
f"/namespaces/{root}/git",
2577+
json={"github_repo_path": "corp/protected-repo", "default_branch": "main"},
2578+
)
2579+
2580+
# Create the default branch namespace
2581+
await module__client_with_all_examples.post(f"/namespaces/{branch_ns}/")
2582+
await module__client_with_all_examples.patch(
2583+
f"/namespaces/{branch_ns}/git",
2584+
json={"parent_namespace": root, "git_branch": "main"},
2585+
)
2586+
2587+
# Soft delete should be blocked
2588+
response = await module__client_with_all_examples.delete(
2589+
f"/namespaces/{branch_ns}/",
2590+
)
2591+
assert response.status_code == 422
2592+
assert "default branch" in response.json()["message"]
2593+
assert "corp/protected-repo" in response.json()["message"]
2594+
2595+
# Soft delete with cascade should also be blocked
2596+
response = await module__client_with_all_examples.delete(
2597+
f"/namespaces/{branch_ns}/?cascade=true",
2598+
)
2599+
assert response.status_code == 422
2600+
assert "default branch" in response.json()["message"]
2601+
2602+
2603+
@pytest.mark.asyncio
2604+
async def test_hard_delete_git_default_branch_namespace_blocked(
2605+
module__client_with_all_examples: AsyncClient,
2606+
) -> None:
2607+
"""
2608+
Hard deleting a git-backed namespace whose git_branch matches its
2609+
default_branch must be rejected.
2610+
"""
2611+
root = "protected.root.hard"
2612+
branch_ns = "protected.root.hard.main"
2613+
2614+
await module__client_with_all_examples.post(f"/namespaces/{root}/")
2615+
await module__client_with_all_examples.patch(
2616+
f"/namespaces/{root}/git",
2617+
json={"github_repo_path": "corp/protected-hard-repo", "default_branch": "main"},
2618+
)
2619+
2620+
await module__client_with_all_examples.post(f"/namespaces/{branch_ns}/")
2621+
await module__client_with_all_examples.patch(
2622+
f"/namespaces/{branch_ns}/git",
2623+
json={"parent_namespace": root, "git_branch": "main"},
2624+
)
2625+
2626+
# Hard delete should be blocked
2627+
response = await module__client_with_all_examples.delete(
2628+
f"/namespaces/{branch_ns}/hard/",
2629+
)
2630+
assert response.status_code == 422
2631+
assert "default branch" in response.json()["message"]
2632+
assert "corp/protected-hard-repo" in response.json()["message"]
2633+
2634+
# Hard delete with cascade should also be blocked
2635+
response = await module__client_with_all_examples.delete(
2636+
f"/namespaces/{branch_ns}/hard/?cascade=true",
2637+
)
2638+
assert response.status_code == 422
2639+
assert "default branch" in response.json()["message"]
2640+
2641+
2642+
@pytest.mark.asyncio
2643+
async def test_delete_non_default_git_branch_namespace_allowed(
2644+
module__client_with_all_examples: AsyncClient,
2645+
) -> None:
2646+
"""
2647+
Deleting a non-default branch namespace should still work normally.
2648+
"""
2649+
root = "protected.root.feature"
2650+
branch_ns = "protected.root.feature.my_feature"
2651+
2652+
await module__client_with_all_examples.post(f"/namespaces/{root}/")
2653+
await module__client_with_all_examples.patch(
2654+
f"/namespaces/{root}/git",
2655+
json={"github_repo_path": "corp/feature-repo", "default_branch": "main"},
2656+
)
2657+
2658+
await module__client_with_all_examples.post(f"/namespaces/{branch_ns}/")
2659+
await module__client_with_all_examples.patch(
2660+
f"/namespaces/{branch_ns}/git",
2661+
json={"parent_namespace": root, "git_branch": "my-feature"},
2662+
)
2663+
2664+
# Soft delete of a non-default branch should succeed (no nodes → deactivates cleanly)
2665+
response = await module__client_with_all_examples.delete(
2666+
f"/namespaces/{branch_ns}/",
2667+
)
2668+
assert response.status_code == 200
2669+
assert "deactivated" in response.json()["message"]

datajunction-server/tests/internal/git/test_validation.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -802,11 +802,11 @@ async def test_is_default_branch_true_when_no_git_branch(
802802
assert result["is_default_branch"] is True
803803

804804
@pytest.mark.asyncio
805-
async def test_is_default_branch_true_when_no_default_branch(
805+
async def test_is_default_branch_false_when_no_default_branch(
806806
self,
807807
session: AsyncSession,
808808
):
809-
"""git_branch set but default_branch is None → defaults to True."""
809+
"""git_branch set but default_branch is None → is_default_branch is False (unknown)."""
810810
session.add(
811811
NodeNamespace(
812812
namespace="proj",
@@ -826,7 +826,7 @@ async def test_is_default_branch_true_when_no_default_branch(
826826
result = await get_git_info_for_namespace(session, "proj.feature_x")
827827

828828
assert result is not None
829-
assert result["is_default_branch"] is True
829+
assert result["is_default_branch"] is False
830830

831831
# ------------------------------------------------------------------
832832
# parent_namespace

0 commit comments

Comments
 (0)