From 339d5cbe775bc27ea4cca3e6d474366fdc017048 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 06:07:59 +0000 Subject: [PATCH] fix(services): omit blank sha so creating a new file doesn't 422 Pushing a new file via the single-file push button failed with GitHub HTTP 422. A 404 lookup returns sha === '' for new files, and the manual push path (sync-manager pushFile/conflict resolution) forwarded that empty string into the Contents API request body as "sha":"", which GitHub rejects. Batch push avoided this via `remote.sha || undefined`. Fix at the service layer so every caller is safe: only include sha in the GitHub request body when it is non-empty. Apply the same guard to the GitLab service's last_commit_id for symmetry. Add regression tests for blank-sha pushes on both services. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/services/github-service.ts | 7 +++++-- src/services/gitlab-service.ts | 5 +++-- tests/services/github-service.test.ts | 15 +++++++++++++++ tests/services/gitlab-service.test.ts | 8 ++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/services/github-service.ts b/src/services/github-service.ts index dbf888f..b086cf4 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -39,12 +39,15 @@ export class GitHubService extends BaseGitService implements GitServiceInterface async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); - const body = { + const body: { message: string; content: string; branch: string; sha?: string } = { message, content: this.encodeContent(content), branch, - sha }; + // GitHub's Contents API rejects a blank sha with HTTP 422. Only include + // it when updating an existing file; a 404 lookup yields sha === '' for + // new files, which must be created without a sha. + if (sha) body.sha = sha; const response = await this.safeRequest(url, 'PUT', body); const data = this.parseJson<{ content: { path: string, sha: string } }>(response); diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 443056a..b172c0e 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -40,13 +40,14 @@ export class GitLabService extends BaseGitService implements GitServiceInterface async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); - const body = { + const body: { branch: string; content: string; encoding: string; commit_message: string; last_commit_id?: string } = { branch, content: this.encodeContent(content), encoding: 'base64', commit_message: message, - last_commit_id: sha }; + // A blank sha means the file is new: create it (POST) without last_commit_id. + if (sha) body.last_commit_id = sha; const method = sha ? 'PUT' : 'POST'; const response = await this.safeRequest(url, method, body); diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 5854ea4..427f8da 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -70,6 +70,21 @@ describe('GitHubService', () => { expect(call.body).not.toContain('"sha":'); }); + it('should omit blank sha so creating a new file does not 422', async () => { + // A 404 lookup yields sha === '' for new files; an empty sha sent to + // GitHub causes HTTP 422, so it must be dropped from the request body. + vi.mocked(requestUrl).mockResolvedValueOnce({ + status: 201, + json: { content: { path: 'new.md', sha: 'new-sha' } } + } as unknown as RequestUrlResponse); + + const result = await service.pushFile('new.md', 'content', 'main', 'create', ''); + + expect(result).toEqual({ path: 'new.md', sha: 'new-sha' }); + const call = getLastRequestCall(); + expect(call.body).not.toContain('"sha":'); + }); + it('should update existing file correctly (sha provided)', async () => { mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } }); diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index d468c13..9738008 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -50,6 +50,14 @@ describe('GitLabService', () => { expect(call.body).toContain(btoa('new content')); }); + it('should treat a blank sha as a new file (POST, no last_commit_id)', async () => { + mockRequest({ status: 201, json: { file_path: 'test.md' } }); + await service.pushFile('test.md', 'new content', 'main', 'initial commit', ''); + const call = getLastRequestCall(); + expect(call.method).toBe('POST'); + expect(call.body).not.toContain('last_commit_id'); + }); + it('should push file content correctly (PUT for existing file)', async () => { mockRequest({ status: 200, json: { file_path: 'test.md' } }); const result = await service.pushFile('test.md', 'updated content', 'main', 'update', 'old-sha');