diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index f0a208b..eac1f32 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -136,6 +136,17 @@ if err != nil { log.Fatal(err) } fmt.Println(deletedBranch.Message) + +// Set Ephemeral to delete a branch from the ephemeral namespace +ephemeral := true +deletedEphemeral, err := repo.DeleteBranch(context.Background(), storage.DeleteBranchOptions{ + Name: "merge/123e4567-e89b-12d3-a456-426614174000", + Ephemeral: &ephemeral, +}) +if err != nil { + log.Fatal(err) +} +fmt.Println(deletedEphemeral.Ephemeral) ``` ### Merge branches diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index d88cc04..ad9cd6e 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -949,7 +949,7 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D return DeleteBranchResult{}, err } - body := &deleteBranchRequest{Name: name} + body := &deleteBranchRequest{Name: name, Ephemeral: options.Ephemeral} resp, err := r.client.api.delete(ctx, "repos/branches", nil, body, jwtToken, nil) if err != nil { return DeleteBranchResult{}, err @@ -962,8 +962,9 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D } return DeleteBranchResult{ - Name: payload.Name, - Message: payload.Message, + Name: payload.Name, + Message: payload.Message, + Ephemeral: payload.Ephemeral, }, nil } diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index c0e4dcf..7427cff 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -1,6 +1,7 @@ package storage import ( + "context" "encoding/json" "errors" "io" @@ -956,6 +957,9 @@ func TestDeleteBranch(t *testing.T) { if body.Name != "feature/old-onboarding" { t.Fatalf("unexpected delete branch payload: %+v", body) } + if body.Ephemeral != nil { + t.Fatalf("expected omitted ephemeral, got %#v", body.Ephemeral) + } token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") claims := parseJWTFromToken(t, token) scopes, ok := claims["scopes"].([]interface{}) @@ -963,7 +967,45 @@ func TestDeleteBranch(t *testing.T) { t.Fatalf("unexpected scopes: %#v", claims["scopes"]) } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"name":"feature/old-onboarding","message":"branch deleted"}`)) + _, _ = w.Write([]byte(`{"name":"feature/old-onboarding","message":"branch deleted","ephemeral":false}`)) + })) + defer server.Close() + + client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL}) + if err != nil { + t.Fatalf("client error: %v", err) + } + repo := &Repo{ID: "repo", DefaultBranch: "main", client: client} + + result, err := repo.DeleteBranch(context.Background(), DeleteBranchOptions{Name: "feature/old-onboarding"}) + if err != nil { + t.Fatalf("delete branch error: %v", err) + } + if result.Name != "feature/old-onboarding" || result.Message != "branch deleted" || result.Ephemeral { + t.Fatalf("unexpected delete branch result: %+v", result) + } +} + +func TestDeleteBranchEphemeral(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/branches" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodDelete { + t.Fatalf("unexpected method: %s", r.Method) + } + var body deleteBranchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Name != "merge/123e4567-e89b-12d3-a456-426614174000" { + t.Fatalf("unexpected delete branch name: %s", body.Name) + } + if body.Ephemeral == nil || !*body.Ephemeral { + t.Fatalf("expected ephemeral=true, got %#v", body.Ephemeral) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"merge/123e4567-e89b-12d3-a456-426614174000","message":"branch deleted","ephemeral":true}`)) })) defer server.Close() @@ -973,11 +1015,15 @@ func TestDeleteBranch(t *testing.T) { } repo := &Repo{ID: "repo", DefaultBranch: "main", client: client} - result, err := repo.DeleteBranch(nil, DeleteBranchOptions{Name: "feature/old-onboarding"}) + ephemeral := true + result, err := repo.DeleteBranch(context.Background(), DeleteBranchOptions{ + Name: "merge/123e4567-e89b-12d3-a456-426614174000", + Ephemeral: &ephemeral, + }) if err != nil { t.Fatalf("delete branch error: %v", err) } - if result.Name != "feature/old-onboarding" || result.Message != "branch deleted" { + if result.Name != "merge/123e4567-e89b-12d3-a456-426614174000" || result.Message != "branch deleted" || !result.Ephemeral { t.Fatalf("unexpected delete branch result: %+v", result) } } diff --git a/packages/code-storage-go/requests.go b/packages/code-storage-go/requests.go index e6fe2e5..0637fa2 100644 --- a/packages/code-storage-go/requests.go +++ b/packages/code-storage-go/requests.go @@ -126,7 +126,8 @@ type deleteTagRequest struct { } type deleteBranchRequest struct { - Name string `json:"name"` + Name string `json:"name"` + Ephemeral *bool `json:"ephemeral,omitempty"` } // commitMetadataPayload is the JSON body for commit metadata. diff --git a/packages/code-storage-go/responses.go b/packages/code-storage-go/responses.go index 65cde81..69e02a5 100644 --- a/packages/code-storage-go/responses.go +++ b/packages/code-storage-go/responses.go @@ -214,8 +214,9 @@ type deleteTagResponse struct { } type deleteBranchResponse struct { - Name string `json:"name"` - Message string `json:"message"` + Name string `json:"name"` + Message string `json:"message"` + Ephemeral bool `json:"ephemeral"` } type grepResponse struct { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index f56309b..e5182b7 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -319,13 +319,15 @@ type CreateBranchResult struct { // DeleteBranchOptions configures branch deletion. type DeleteBranchOptions struct { InvocationOptions - Name string + Name string + Ephemeral *bool } // DeleteBranchResult describes branch deletion result. type DeleteBranchResult struct { - Name string - Message string + Name string + Message string + Ephemeral bool } // MergeStrategy selects how Repo.Merge reconciles source into target. diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 6ce9397..d900f99 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -210,6 +210,13 @@ print(branch_result["target_branch"], branch_result.get("commit_sha")) delete_branch_result = await repo.delete_branch(name="feature/old-onboarding") print(delete_branch_result["message"]) +# Pass ephemeral=True to delete a branch from the ephemeral namespace +ephemeral_delete = await repo.delete_branch( + name="merge/123e4567-e89b-12d3-a456-426614174000", + ephemeral=True, +) +print(ephemeral_delete["ephemeral"]) # True + # Merge one branch into another merge_result = await repo.merge( source_branch="feature/preview", @@ -722,6 +729,7 @@ class Repo: self, *, name: str, + ephemeral: Optional[bool] = None, ttl: Optional[int] = None, ) -> DeleteBranchResult: ... diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index 1f1dbf6..3dac06d 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -650,9 +650,14 @@ async def delete_branch( self, *, name: str, + ephemeral: Optional[bool] = None, ttl: Optional[int] = None, ) -> DeleteBranchResult: - """Delete a branch.""" + """Delete a branch. + + When ``ephemeral`` is true, the branch is resolved and removed under the + repository's ephemeral namespace rather than the persistent one. + """ name_clean = name.strip() if not name_clean: raise ValueError("delete_branch name is required") @@ -667,6 +672,10 @@ async def delete_branch( url = f"{self.api_base_url}/api/v{self.api_version}/repos/branches" + body: dict[str, object] = {"name": name_clean} + if ephemeral is not None: + body["ephemeral"] = bool(ephemeral) + async with httpx.AsyncClient() as client: response = await client.request( "DELETE", @@ -676,7 +685,7 @@ async def delete_branch( "Content-Type": "application/json", "Code-Storage-Agent": get_user_agent(), }, - json={"name": name_clean}, + json=body, timeout=30.0, ) @@ -698,6 +707,7 @@ async def delete_branch( return { "name": data["name"], "message": data["message"], + "ephemeral": bool(data.get("ephemeral", False)), } async def merge( diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index d422b30..73f449b 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -242,6 +242,7 @@ class DeleteBranchResult(TypedDict): name: str message: str + ephemeral: bool # Removed: ListCommitsOptions - now uses **kwargs @@ -706,6 +707,7 @@ async def delete_branch( self, *, name: str, + ephemeral: Optional[bool] = None, ttl: Optional[int] = None, ) -> DeleteBranchResult: """Delete a branch.""" diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index 468470a..33efc33 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -1869,6 +1869,7 @@ async def test_delete_branch(self, git_storage_options: dict) -> None: delete_branch_response.json.return_value = { "name": "feature/old-onboarding", "message": "branch deleted", + "ephemeral": False, } with patch("httpx.AsyncClient") as mock_client: @@ -1882,6 +1883,7 @@ async def test_delete_branch(self, git_storage_options: dict) -> None: assert result == { "name": "feature/old-onboarding", "message": "branch deleted", + "ephemeral": False, } delete_call = client_instance.request.call_args_list[0] @@ -1889,6 +1891,48 @@ async def test_delete_branch(self, git_storage_options: dict) -> None: assert delete_call.args[1].endswith("/repos/branches") assert delete_call.kwargs["json"] == {"name": "feature/old-onboarding"} + @pytest.mark.asyncio + async def test_delete_branch_ephemeral(self, git_storage_options: dict) -> None: + """Test that delete_branch forwards the ephemeral flag and surfaces it.""" + storage = GitStorage(git_storage_options) + + create_response = MagicMock() + create_response.status_code = 200 + create_response.is_success = True + create_response.json.return_value = {"repo_id": "test-repo"} + + delete_branch_response = MagicMock() + delete_branch_response.status_code = 200 + delete_branch_response.is_success = True + delete_branch_response.json.return_value = { + "name": "merge/123e4567-e89b-12d3-a456-426614174000", + "message": "branch deleted", + "ephemeral": True, + } + + with patch("httpx.AsyncClient") as mock_client: + client_instance = mock_client.return_value.__aenter__.return_value + client_instance.post = AsyncMock(return_value=create_response) + client_instance.request = AsyncMock(return_value=delete_branch_response) + + repo = await storage.create_repo(id="test-repo") + + result = await repo.delete_branch( + name="merge/123e4567-e89b-12d3-a456-426614174000", + ephemeral=True, + ) + assert result == { + "name": "merge/123e4567-e89b-12d3-a456-426614174000", + "message": "branch deleted", + "ephemeral": True, + } + + delete_call = client_instance.request.call_args_list[0] + assert delete_call.kwargs["json"] == { + "name": "merge/123e4567-e89b-12d3-a456-426614174000", + "ephemeral": True, + } + @pytest.mark.asyncio async def test_delete_branch_validates_name(self, git_storage_options: dict) -> None: """Test that delete_branch validates the branch name.""" diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index b429a8a..b0b2108 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -306,6 +306,13 @@ console.log(branch.targetBranch, branch.commitSha); const deletedBranch = await repo.deleteBranch({ name: 'feature/demo' }); console.log(deletedBranch.message); +// Pass `ephemeral: true` to delete a branch from the ephemeral namespace +const ephemeralDelete = await repo.deleteBranch({ + name: 'merge/123e4567-e89b-12d3-a456-426614174000', + ephemeral: true, +}); +console.log(ephemeralDelete.ephemeral); // true + // `baseBranch` is still accepted for backwards compatibility, but deprecated. // Prefer `baseRef` for new code. diff --git a/packages/code-storage-typescript/src/index.ts b/packages/code-storage-typescript/src/index.ts index 6aee17f..9aa82b1 100644 --- a/packages/code-storage-typescript/src/index.ts +++ b/packages/code-storage-typescript/src/index.ts @@ -562,6 +562,7 @@ function transformDeleteBranchResult( return { name: raw.name, message: raw.message, + ephemeral: raw.ephemeral ?? false, }; } @@ -1463,8 +1464,13 @@ class RepoImpl implements Repo { ttl, }); + const body: Record = { name }; + if (typeof options.ephemeral === 'boolean') { + body.ephemeral = options.ephemeral; + } + const response = await this.api.delete( - { path: 'repos/branches', body: { name } }, + { path: 'repos/branches', body }, jwt ); const raw = deleteBranchResponseSchema.parse(await response.json()); diff --git a/packages/code-storage-typescript/src/schemas.ts b/packages/code-storage-typescript/src/schemas.ts index 0b22d67..3876af2 100644 --- a/packages/code-storage-typescript/src/schemas.ts +++ b/packages/code-storage-typescript/src/schemas.ts @@ -216,6 +216,7 @@ export const deleteTagResponseSchema = z.object({ export const deleteBranchResponseSchema = z.object({ name: z.string(), message: z.string(), + ephemeral: z.boolean().optional(), }); export const refUpdateResultSchema = z.object({ diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 590274c..7f39956 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -350,6 +350,7 @@ export interface CreateBranchResult { export interface DeleteBranchOptions extends GitStorageInvocationOptions { name: string; + ephemeral?: boolean; } export type DeleteBranchResponse = DeleteBranchResponseRaw; @@ -357,6 +358,7 @@ export type DeleteBranchResponse = DeleteBranchResponseRaw; export interface DeleteBranchResult { name: string; message: string; + ephemeral: boolean; } export interface ListTagsOptions extends GitStorageInvocationOptions { diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index 7ea58e7..f266de8 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -1967,6 +1967,7 @@ describe('GitStorage', () => { json: async () => ({ name: 'feature/old-onboarding', message: 'branch deleted', + ephemeral: false, }), } as any); }); @@ -1977,6 +1978,44 @@ describe('GitStorage', () => { expect(result).toEqual({ name: 'feature/old-onboarding', message: 'branch deleted', + ephemeral: false, + }); + }); + + it('forwards ephemeral flag in the request body and surfaces it on the result', async () => { + const store = new GitStorage({ name: 'v0', key }); + const repo = await store.createRepo({ id: 'repo-delete-branch-ephemeral' }); + + mockFetch.mockImplementationOnce((_url, init) => { + const requestInit = init as RequestInit; + expect(requestInit.method).toBe('DELETE'); + + const body = JSON.parse(requestInit.body as string); + expect(body).toEqual({ + name: 'merge/123e4567-e89b-12d3-a456-426614174000', + ephemeral: true, + }); + + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + name: 'merge/123e4567-e89b-12d3-a456-426614174000', + message: 'branch deleted', + ephemeral: true, + }), + } as any); + }); + + const result = await repo.deleteBranch({ + name: 'merge/123e4567-e89b-12d3-a456-426614174000', + ephemeral: true, + }); + expect(result).toEqual({ + name: 'merge/123e4567-e89b-12d3-a456-426614174000', + message: 'branch deleted', + ephemeral: true, }); }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index d1c44d3..9f58b38 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -332,7 +332,11 @@ curl "$CODE_STORAGE_BASE_URL/repos/branches" -X DELETE \ The default branch cannot be deleted. If the repository is connected to GitHub sync, branch deletion triggers a sync automatically. -Response: `{ "name": "feature/old-onboarding", "message": "branch deleted" }` +Response: `{ "name": "feature/old-onboarding", "message": "branch deleted", "ephemeral": false }` + +Pass `"ephemeral": true` in the body to delete a branch under the ephemeral namespace. When `ephemeral` is true the default-branch protection is skipped +(the default branch is always non-ephemeral) and GitHub mirroring is not triggered. The response +echoes which namespace the deletion targeted via the `ephemeral` field. ## POST /repos/commit-pack — Create Commit