From 708ba1f9708c84ab4db4fb2f9348e5335db17378 Mon Sep 17 00:00:00 2001 From: Zac Duncan Date: Thu, 18 Jun 2026 15:28:03 -0500 Subject: [PATCH] handle new optional merge retry semantics --- packages/code-storage-go/README.md | 2 ++ packages/code-storage-go/repo.go | 4 ++++ packages/code-storage-go/repo_test.go | 4 ++-- packages/code-storage-go/types.go | 10 ++++++---- packages/code-storage-python/README.md | 7 ++++++- packages/code-storage-python/pierre_storage/repo.py | 8 +++++++- packages/code-storage-python/pierre_storage/types.py | 2 ++ packages/code-storage-python/tests/test_repo.py | 8 ++++---- packages/code-storage-typescript/README.md | 8 +++++++- packages/code-storage-typescript/src/types.ts | 6 ++++++ packages/code-storage-typescript/tests/index.test.ts | 6 ++---- skills/code-storage/SKILL.md | 10 +++++++++- 12 files changed, 57 insertions(+), 18 deletions(-) diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index 817f2af..cf19fd5 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -174,6 +174,8 @@ result, err := repo.Merge(context.Background(), storage.MergeOptions{ SourceBranch: "feature", SourceIsEphemeral: true, TargetBranch: "main", + // Leave ExpectedTargetSHA empty to merge into the current target tip. + // Set it to require TargetBranch to still point at that commit; moved targets return 409. Strategy: storage.MergeStrategyMerge, Author: &storage.CommitSignature{Name: "Merge Bot", Email: "merge@example.com"}, }) diff --git a/packages/code-storage-go/repo.go b/packages/code-storage-go/repo.go index 723b99c..c36229e 100644 --- a/packages/code-storage-go/repo.go +++ b/packages/code-storage-go/repo.go @@ -1106,6 +1106,10 @@ func (r *Repo) DeleteBranch(ctx context.Context, options DeleteBranchOptions) (D } // Merge merges a source branch into a target branch. +// Set ExpectedTargetSHA to require the target branch to still point at that commit. +// The server returns 409 if it moved. Leave ExpectedTargetSHA empty to merge into the +// current target tip. Native Code Storage targets may retry stale target/repository +// movement while preserving the resolved source commit. func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, error) { sourceBranch := strings.TrimSpace(options.SourceBranch) if sourceBranch == "" { diff --git a/packages/code-storage-go/repo_test.go b/packages/code-storage-go/repo_test.go index 4651c9d..d2deff5 100644 --- a/packages/code-storage-go/repo_test.go +++ b/packages/code-storage-go/repo_test.go @@ -809,7 +809,7 @@ func TestCreateBranchTTL(t *testing.T) { } } -func TestMergeRequestAndResponse(t *testing.T) { +func TestMergeGuardedTargetTipRequestAndResponse(t *testing.T) { var captured map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -903,7 +903,7 @@ func TestMergeRequestAndResponse(t *testing.T) { } } -func TestMergeOmitsOptionalFields(t *testing.T) { +func TestMergeCurrentTargetTipModeOmitsExpectedTargetSHA(t *testing.T) { var captured map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/merge" { diff --git a/packages/code-storage-go/types.go b/packages/code-storage-go/types.go index 30f7cb8..da9db97 100644 --- a/packages/code-storage-go/types.go +++ b/packages/code-storage-go/types.go @@ -430,10 +430,12 @@ const ( // MergeOptions configures branch merge operations. type MergeOptions struct { InvocationOptions - SourceBranch string - SourceIsEphemeral bool - TargetBranch string - TargetIsEphemeral bool + SourceBranch string + SourceIsEphemeral bool + TargetBranch string + TargetIsEphemeral bool + // ExpectedTargetSHA, when non-empty, requires the target branch to still point at + // that commit. Leave empty to merge into the current target tip. ExpectedTargetSHA string CommitMessage string Author *CommitSignature diff --git a/packages/code-storage-python/README.md b/packages/code-storage-python/README.md index 2bccc6d..01a46bf 100644 --- a/packages/code-storage-python/README.md +++ b/packages/code-storage-python/README.md @@ -237,7 +237,7 @@ merge_result = await repo.merge( target_branch="main", target_is_ephemeral=False, # optional; target branch can independently be ephemeral strategy="merge", # one of: "merge", "ff_only", "ff_prefer" - expected_target_sha="abc123", # optional optimistic concurrency check + expected_target_sha="abc123", # optional; 409 if target moved commit_message="Merge feature/preview", # optional author={"name": "Bot", "email": "bot@example.com"}, # optional committer={"name": "Bot", "email": "bot@example.com"}, # optional @@ -246,6 +246,11 @@ merge_result = await repo.merge( ) print(merge_result["result"], merge_result["commit_sha"]) print(merge_result["source"]["sha"], merge_result["target"]["new_sha"]) +# Target-tip modes: +# - Provide expected_target_sha when target_branch must still point at that commit. +# - Omit expected_target_sha to merge into the current target tip. For native +# Code Storage targets, the gateway may retry stale target/repository movement +# internally while keeping the resolved source commit pinned. # List tags tags = await repo.list_tags(limit=10) diff --git a/packages/code-storage-python/pierre_storage/repo.py b/packages/code-storage-python/pierre_storage/repo.py index c6cc7e7..9f0e84a 100644 --- a/packages/code-storage-python/pierre_storage/repo.py +++ b/packages/code-storage-python/pierre_storage/repo.py @@ -970,7 +970,13 @@ async def merge( ttl: Optional[int] = None, ref_policies: Optional[Refs] = None, ) -> MergeBranchesResult: - """Merge a source branch into a target branch.""" + """Merge a source branch into a target branch. + + Provide expected_target_sha to require the target branch to still point at that + commit. Omit it to merge into the current target tip; native Code Storage + targets may retry stale target/repository movement while preserving the + resolved source commit. + """ source_branch_clean = source_branch.strip() target_branch_clean = target_branch.strip() strategy_clean = strategy.strip() diff --git a/packages/code-storage-python/pierre_storage/types.py b/packages/code-storage-python/pierre_storage/types.py index 9a32f00..b47896f 100644 --- a/packages/code-storage-python/pierre_storage/types.py +++ b/packages/code-storage-python/pierre_storage/types.py @@ -534,6 +534,8 @@ class MergeBranchesOptions(TypedDict, total=False): source_is_ephemeral: bool target_branch: str # required target_is_ephemeral: bool + # When provided, target must still point at this SHA; omit to merge current tip. + # Native Code Storage targets may retry stale movement while preserving source commit. expected_target_sha: str commit_message: str author: CommitSignature diff --git a/packages/code-storage-python/tests/test_repo.py b/packages/code-storage-python/tests/test_repo.py index f4bbd92..9ef2505 100644 --- a/packages/code-storage-python/tests/test_repo.py +++ b/packages/code-storage-python/tests/test_repo.py @@ -1252,8 +1252,10 @@ async def test_merge_posts_body_and_parses_response(self, git_storage_options: d assert payload["exp"] - payload["iat"] == 900 @pytest.mark.asyncio - async def test_merge_omits_blank_optional_fields(self, git_storage_options: dict) -> None: - """Blank optional merge fields should be omitted from the request body.""" + async def test_merge_omits_expected_target_sha_for_current_target_tip( + self, git_storage_options: dict + ) -> None: + """Omitted expected_target_sha requests merge into the current target tip.""" storage = GitStorage(git_storage_options) create_repo_response = MagicMock() @@ -1287,8 +1289,6 @@ async def test_merge_omits_blank_optional_fields(self, git_storage_options: dict source_branch="feature", target_branch="main", strategy="ff_prefer", - expected_target_sha=" ", - commit_message=None, ) assert "merge_base_sha" not in result diff --git a/packages/code-storage-typescript/README.md b/packages/code-storage-typescript/README.md index 4912d03..023f376 100644 --- a/packages/code-storage-typescript/README.md +++ b/packages/code-storage-typescript/README.md @@ -341,7 +341,7 @@ const mergeResult = await repo.merge({ sourceIsEphemeral: true, targetBranch: 'main', targetIsEphemeral: false, - expectedTargetSha: '0123456789abcdef0123456789abcdef01234567', // optional guard + expectedTargetSha: '0123456789abcdef0123456789abcdef01234567', // optional; 409 if target moved strategy: 'merge', // 'merge' | 'ff_only' | 'ff_prefer' commitMessage: 'Merge feature/demo', // optional author: { name: 'Merge Bot', email: 'merge@example.com' }, // optional @@ -357,6 +357,11 @@ console.log(mergeResult.commitSha, mergeResult.target.newSha); // reported), and number of promoted commits. A backend conflict response // (HTTP 409) is surfaced as an API error with the response body preserved for // callers that need conflict_paths or merge_base_sha. +// Target-tip modes: +// - Provide expectedTargetSha when targetBranch must still point at that commit. +// - Omit expectedTargetSha to merge into the current target tip. For native +// Code Storage targets, the gateway may retry stale target/repository +// movement internally while keeping the resolved source commit pinned. // Create a commit using the streaming helper const fs = await import('node:fs/promises'); @@ -899,6 +904,7 @@ interface MergeOptions { sourceIsEphemeral?: boolean; targetBranch: string; targetIsEphemeral?: boolean; + // Optional target-tip guard. Omit to merge into the current target tip. expectedTargetSha?: string; commitMessage?: string; author?: CommitSignature; diff --git a/packages/code-storage-typescript/src/types.ts b/packages/code-storage-typescript/src/types.ts index 16dba47..e663b7e 100644 --- a/packages/code-storage-typescript/src/types.ts +++ b/packages/code-storage-typescript/src/types.ts @@ -929,6 +929,12 @@ export interface MergeOptions sourceIsEphemeral?: boolean; targetBranch: string; targetIsEphemeral?: boolean; + /** + * When provided, the target branch must still point at this commit; the merge + * fails with HTTP 409 if it moved. Omit to merge into the current target tip. + * Native Code Storage targets may retry stale target/repository movement while + * preserving the resolved source commit. + */ expectedTargetSha?: string; commitMessage?: string; author?: CommitSignature; diff --git a/packages/code-storage-typescript/tests/index.test.ts b/packages/code-storage-typescript/tests/index.test.ts index fc9cf06..90f88c2 100644 --- a/packages/code-storage-typescript/tests/index.test.ts +++ b/packages/code-storage-typescript/tests/index.test.ts @@ -2087,7 +2087,7 @@ describe('GitStorage', () => { }); describe('Repo merge', () => { - it('posts merge request and returns transformed response', async () => { + it('posts guarded target-tip merge request and returns transformed response', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = store.repo({ id: 'repo-merge' }); @@ -2166,7 +2166,7 @@ describe('GitStorage', () => { }); }); - it('omits blank optional string fields from merge request', async () => { + it('omits expectedTargetSha for current target tip mode', async () => { const store = new GitStorage({ name: 'v0', key }); const repo = store.repo({ id: 'repo-merge-minimal' }); @@ -2201,8 +2201,6 @@ describe('GitStorage', () => { await repo.merge({ sourceBranch: 'feature', targetBranch: 'main', - expectedTargetSha: ' ', - commitMessage: '', strategy: 'ff_prefer', }); }); diff --git a/skills/code-storage/SKILL.md b/skills/code-storage/SKILL.md index 4393856..3ca81bc 100644 --- a/skills/code-storage/SKILL.md +++ b/skills/code-storage/SKILL.md @@ -362,6 +362,14 @@ curl "$CODE_STORAGE_BASE_URL/repos/merge" -X POST \ Required: `source_branch`, `target_branch`, `strategy` (`merge` | `ff_only` | `ff_prefer`). Optional: `source_is_ephemeral`, `target_is_ephemeral`, `expected_target_sha`, `commit_message`, `author`, `committer`, `allow_unrelated_histories`, `squash`. + +Target-tip modes: +- Set `expected_target_sha` when the target branch must still point at a specific + commit; movement returns HTTP 409. +- Omit `expected_target_sha` to merge into the current target tip. For native Code + Storage targets, the gateway may retry stale target/repository movement internally + while keeping the originally resolved source commit pinned. + Set `squash: true` to collapse the source into a single new commit whose only parent is the current target tip. It is incompatible with `ff_only`. Response: `{ "result": "merge_commit"|"fast_forward"|"no_op"|"squash"|"unknown", @@ -1033,4 +1041,4 @@ git push origin feature-branch | Blob data encoding | Always base64. Max 4 MiB per chunk. Use multiple chunks for large files. | | `expected_head_sha` | Optimistic lock. Provide current branch tip SHA to enforce fast-forward semantics. | | Policy ops | JWT-level guards via `refPolicies` (per-ref, first match wins, preferred). `no-force-push` (TS/Py `OP_NO_FORCE_PUSH`, Go `OpNoForcePush`) blocks non-FF updates. `no-push` (`OP_NO_PUSH`/`OpNoPush`) blocks pushes to matching refs. `verify-sig` (`OP_VERIFY_SIG`/`OpVerifySig`) blocks pushes introducing commits not signed by a registered signing key. Top-level `ops` is a legacy alias on URL-minting methods only. | -| Merge endpoint | `POST /repos/merge`. Strategies: `merge`, `ff_only`, `ff_prefer`. Optional `squash` (not with `ff_only`). 409 on conflict. | +| Merge endpoint | `POST /repos/merge`. Strategies: `merge`, `ff_only`, `ff_prefer`. Optional `expected_target_sha` guards the target tip (409 if moved); omit it to merge into the current target tip. Optional `squash` (not with `ff_only`). 409 on conflict. |