Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
67 changes: 50 additions & 17 deletions internal/github/sync_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -517,19 +538,28 @@ 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)
if err != nil {
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"
}
Expand All @@ -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)
Expand Down