From afc9b63f897d68e45b5a2ce9596c708d6fd7e875 Mon Sep 17 00:00:00 2001 From: javanhut Date: Sat, 24 Jan 2026 16:20:16 +0000 Subject: [PATCH] fix: implemented fix for ivaldi upload to empty repo --- internal/config/config_test.go | 5 +++ internal/github/client.go | 30 +++++++++++++++ internal/github/sync_push.go | 67 +++++++++++++++++++++++++--------- 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f6cfb9c..d9d470f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -222,6 +222,11 @@ func TestGetAuthor(t *testing.T) { } defer os.Chdir(originalDir) + // Also isolate from global config by changing HOME + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + // Test that GetAuthor fails when user.name and user.email are not set _, err = GetAuthor() if err == nil { diff --git a/internal/github/client.go b/internal/github/client.go index 9dc3702..ac3ecd6 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -720,6 +720,18 @@ type FileUploadRequest struct { Branch string `json:"branch,omitempty"` } +// FileUploadResponse represents the response from creating/updating a file +type FileUploadResponse struct { + Content *FileContent `json:"content"` + Commit struct { + SHA string `json:"sha"` + Message string `json:"message"` + Tree struct { + SHA string `json:"sha"` + } `json:"tree"` + } `json:"commit"` +} + // UploadFile uploads or updates a file in a repository func (c *Client) UploadFile(ctx context.Context, owner, repo, path string, req FileUploadRequest) error { apiPath := fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, path) @@ -734,6 +746,24 @@ func (c *Client) UploadFile(ctx context.Context, owner, repo, path string, req F return nil } +// UploadFileWithResponse uploads a file and returns the response with commit info +func (c *Client) UploadFileWithResponse(ctx context.Context, owner, repo, path string, req FileUploadRequest) (*FileUploadResponse, error) { + apiPath := fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, path) + + resp, err := c.doRequest(ctx, "PUT", apiPath, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var uploadResp FileUploadResponse + if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { + return nil, fmt.Errorf("failed to decode upload response: %w", err) + } + + return &uploadResp, nil +} + // TestAuth tests if authentication is working func (c *Client) TestAuth(ctx context.Context) error { resp, err := c.doRequest(ctx, "GET", "/user", nil) diff --git a/internal/github/sync_push.go b/internal/github/sync_push.go index dd41255..8c218db 100644 --- a/internal/github/sync_push.go +++ b/internal/github/sync_push.go @@ -374,6 +374,27 @@ func (rs *RepoSyncer) createBlobsParallel(ctx context.Context, owner, repo strin return treeEntries, nil } +// bootstrapEmptyRepo initializes an empty GitHub repository using Contents API. +// This creates a temporary commit so the Git Data API becomes usable. +// Returns the commit SHA (used only to verify repo is no longer empty). +func (rs *RepoSyncer) bootstrapEmptyRepo(ctx context.Context, owner, repo, branch string) (string, error) { + // Minimal temporary content - will be replaced by orphan commit + bootstrapContent := []byte("initializing") + + uploadReq := FileUploadRequest{ + Message: "Initialize repository", + Content: base64.StdEncoding.EncodeToString(bootstrapContent), + Branch: branch, + } + + uploadResp, err := rs.client.UploadFileWithResponse(ctx, owner, repo, ".ivaldi-bootstrap", uploadReq) + if err != nil { + return "", fmt.Errorf("failed to bootstrap: %w", err) + } + + return uploadResp.Commit.SHA, nil +} + // UploadFile uploads a file to GitHub func (rs *RepoSyncer) UploadFile(ctx context.Context, owner, repo, path, branch, message string) error { // Read file content @@ -517,11 +538,20 @@ func (rs *RepoSyncer) PushCommit(ctx context.Context, owner, repo, branch string return fmt.Errorf("failed to list files: %w", err) } - // Empty repository case: use parallel Git Data API for better performance + // Empty repository case: bootstrap then use Git Data API if parentSHA == "" { - fmt.Printf("Initial upload to empty repository: uploading %d files in parallel\n", len(files)) + fmt.Printf("Initial upload to empty repository: %d files\n", len(files)) + + // Phase 1: Bootstrap - create temp commit so Git Data API works + fmt.Printf("Initializing repository...\n") + _, err := rs.bootstrapEmptyRepo(ctx, owner, repo, branch) + if err != nil { + return fmt.Errorf("failed to initialize empty repo: %w", err) + } + + // Phase 2: Upload all blobs via Git Data API (now works) + fmt.Printf("Uploading %d files...\n", len(files)) - // Build change list for all files var initialChanges []FileChange for _, filePath := range files { content, err := commitReader.GetFileContent(tree, filePath) @@ -529,7 +559,7 @@ func (rs *RepoSyncer) PushCommit(ctx context.Context, owner, repo, branch string return fmt.Errorf("failed to get content for %s: %w", filePath, err) } - mode := "100644" // regular file + mode := "100644" if len(content) > 0 && content[0] == '#' && bytes.Contains(content[:min(100, len(content))], []byte("!/")) { mode = "100755" } @@ -542,42 +572,45 @@ func (rs *RepoSyncer) PushCommit(ctx context.Context, owner, repo, branch string }) } - // Upload all blobs in parallel initialTreeEntries, err := rs.createBlobsParallel(ctx, owner, repo, initialChanges) if err != nil { - return fmt.Errorf("failed to create blobs for empty repo: %w", err) + return fmt.Errorf("failed to create blobs: %w", err) } - // Create tree on GitHub (no base tree for empty repo) + // Phase 3: Create tree with only user files (no base_tree) initialTreeReq := CreateTreeRequest{ Tree: initialTreeEntries, } initialTreeResp, err := rs.client.CreateTree(ctx, owner, repo, initialTreeReq) if err != nil { - return fmt.Errorf("failed to create tree for empty repo: %w", err) + return fmt.Errorf("failed to create tree: %w", err) } - // Create commit on GitHub (no parents for initial commit) + // Phase 4: Create ORPHAN commit (no parents = initial commit) initialCommitReq := CreateCommitRequest{ Message: commitObj.Message, Tree: initialTreeResp.SHA, - Parents: []string{}, // Empty for initial commit + Parents: []string{}, // Empty = orphan commit } initialCommitResp, err := rs.client.CreateGitCommit(ctx, owner, repo, initialCommitReq) if err != nil { - return fmt.Errorf("failed to create commit for empty repo: %w", err) + return fmt.Errorf("failed to create commit: %w", err) } - // Create branch reference pointing to the new commit - err = rs.client.CreateBranch(ctx, owner, repo, branch, initialCommitResp.SHA) + // Phase 5: Force update branch to point to orphan commit + // This replaces the bootstrap commit entirely + updateReq := UpdateRefRequest{ + SHA: initialCommitResp.SHA, + Force: true, // Force required to replace bootstrap commit + } + err = rs.client.UpdateRef(ctx, owner, repo, fmt.Sprintf("heads/%s", branch), updateReq) if err != nil { - return fmt.Errorf("failed to create branch reference: %w", err) + return fmt.Errorf("failed to update branch: %w", err) } - fmt.Printf("Successfully uploaded %d files to empty repository\n", len(files)) - fmt.Printf("Created branch '%s' with initial commit %s\n", branch, initialCommitResp.SHA[:7]) + fmt.Printf("Successfully uploaded %d files\n", len(files)) + fmt.Printf("Created commit %s on branch '%s'\n", initialCommitResp.SHA[:7], branch) - // Store GitHub commit SHA in timeline err = rs.updateTimelineWithGitHubSHA(branch, commitHash, initialCommitResp.SHA) if err != nil { logging.Warn("Failed to update timeline with GitHub SHA", "error", err)