Skip to content

Commit 2b352ab

Browse files
authored
Improvements to push_files tool (#1676)
* Fallback to default branch in get_file_contents when main doesn't exist * Addressing review comments * Improvements to push_files tool * Fixed copilot comments * Addressing review comments * Remove debug statement
1 parent 905a08f commit 2b352ab

File tree

3 files changed

+801
-261
lines changed

3 files changed

+801
-261
lines changed

pkg/github/repositories.go

Lines changed: 70 additions & 259 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
ghErrors "github.com/github/github-mcp-server/pkg/errors"
1313
"github.com/github/github-mcp-server/pkg/inventory"
1414
"github.com/github/github-mcp-server/pkg/octicons"
15-
"github.com/github/github-mcp-server/pkg/raw"
1615
"github.com/github/github-mcp-server/pkg/translations"
1716
"github.com/github/github-mcp-server/pkg/utils"
1817
"github.com/google/go-github/v79/github"
@@ -1279,28 +1278,74 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
12791278
}
12801279

12811280
// Get the reference for the branch
1281+
var repositoryIsEmpty bool
1282+
var branchNotFound bool
12821283
ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch)
12831284
if err != nil {
1284-
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1285-
"failed to get branch reference",
1286-
resp,
1287-
err,
1288-
), nil, nil
1285+
ghErr, isGhErr := err.(*github.ErrorResponse)
1286+
if isGhErr {
1287+
if ghErr.Response.StatusCode == http.StatusConflict && ghErr.Message == "Git Repository is empty." {
1288+
repositoryIsEmpty = true
1289+
} else if ghErr.Response.StatusCode == http.StatusNotFound {
1290+
branchNotFound = true
1291+
}
1292+
}
1293+
1294+
if !repositoryIsEmpty && !branchNotFound {
1295+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1296+
"failed to get branch reference",
1297+
resp,
1298+
err,
1299+
), nil, nil
1300+
}
1301+
}
1302+
// Only close resp if it's not nil and not an error case where resp might be nil
1303+
if resp != nil && resp.Body != nil {
1304+
defer func() { _ = resp.Body.Close() }()
12891305
}
1290-
defer func() { _ = resp.Body.Close() }()
12911306

1292-
// Get the commit object that the branch points to
1293-
baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
1294-
if err != nil {
1295-
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1296-
"failed to get base commit",
1297-
resp,
1298-
err,
1299-
), nil, nil
1307+
var baseCommit *github.Commit
1308+
if !repositoryIsEmpty {
1309+
if branchNotFound {
1310+
ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch)
1311+
if err != nil {
1312+
return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil
1313+
}
1314+
}
1315+
1316+
// Get the commit object that the branch points to
1317+
baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
1318+
if err != nil {
1319+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1320+
"failed to get base commit",
1321+
resp,
1322+
err,
1323+
), nil, nil
1324+
}
1325+
if resp != nil && resp.Body != nil {
1326+
defer func() { _ = resp.Body.Close() }()
1327+
}
1328+
} else {
1329+
var base *github.Commit
1330+
// Repository is empty, need to initialize it first
1331+
ref, base, err = initializeRepository(ctx, client, owner, repo)
1332+
if err != nil {
1333+
return utils.NewToolResultError(fmt.Sprintf("failed to initialize repository: %v", err)), nil, nil
1334+
}
1335+
1336+
defaultBranch := strings.TrimPrefix(*ref.Ref, "refs/heads/")
1337+
if branch != defaultBranch {
1338+
// Create the requested branch from the default branch
1339+
ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch)
1340+
if err != nil {
1341+
return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil
1342+
}
1343+
}
1344+
1345+
baseCommit = base
13001346
}
1301-
defer func() { _ = resp.Body.Close() }()
13021347

1303-
// Create tree entries for all files
1348+
// Create tree entries for all files (or remaining files if empty repo)
13041349
var entries []*github.TreeEntry
13051350

13061351
for _, file := range filesObj {
@@ -1328,7 +1373,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
13281373
})
13291374
}
13301375

1331-
// Create a new tree with the file entries
1376+
// Create a new tree with the file entries (baseCommit is now guaranteed to exist)
13321377
newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries)
13331378
if err != nil {
13341379
return ghErrors.NewGitHubAPIErrorResponse(ctx,
@@ -1337,9 +1382,11 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
13371382
err,
13381383
), nil, nil
13391384
}
1340-
defer func() { _ = resp.Body.Close() }()
1385+
if resp != nil && resp.Body != nil {
1386+
defer func() { _ = resp.Body.Close() }()
1387+
}
13411388

1342-
// Create a new commit
1389+
// Create a new commit (baseCommit always has a value now)
13431390
commit := github.Commit{
13441391
Message: github.Ptr(message),
13451392
Tree: newTree,
@@ -1353,7 +1400,9 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
13531400
err,
13541401
), nil, nil
13551402
}
1356-
defer func() { _ = resp.Body.Close() }()
1403+
if resp != nil && resp.Body != nil {
1404+
defer func() { _ = resp.Body.Close() }()
1405+
}
13571406

13581407
// Update the reference to point to the new commit
13591408
ref.Object.SHA = newCommit.SHA
@@ -1770,244 +1819,6 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool
17701819
)
17711820
}
17721821

1773-
// matchFiles searches for files in the Git tree that match the given path.
1774-
// It's used when GetContents fails or returns unexpected results.
1775-
func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) {
1776-
// Step 1: Get Git Tree recursively
1777-
tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true)
1778-
if err != nil {
1779-
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1780-
"failed to get git tree",
1781-
response,
1782-
err,
1783-
), nil, nil
1784-
}
1785-
defer func() { _ = response.Body.Close() }()
1786-
1787-
// Step 2: Filter tree for matching paths
1788-
const maxMatchingFiles = 3
1789-
matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles)
1790-
if len(matchingFiles) > 0 {
1791-
matchingFilesJSON, err := json.Marshal(matchingFiles)
1792-
if err != nil {
1793-
return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil
1794-
}
1795-
resolvedRefs, err := json.Marshal(rawOpts)
1796-
if err != nil {
1797-
return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil
1798-
}
1799-
if rawAPIResponseCode > 0 {
1800-
return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil
1801-
}
1802-
return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil
1803-
}
1804-
return utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil, nil
1805-
}
1806-
1807-
// filterPaths filters the entries in a GitHub tree to find paths that
1808-
// match the given suffix.
1809-
// maxResults limits the number of results returned to first maxResults entries,
1810-
// a maxResults of -1 means no limit.
1811-
// It returns a slice of strings containing the matching paths.
1812-
// Directories are returned with a trailing slash.
1813-
func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string {
1814-
// Remove trailing slash for matching purposes, but flag whether we
1815-
// only want directories.
1816-
dirOnly := false
1817-
if strings.HasSuffix(path, "/") {
1818-
dirOnly = true
1819-
path = strings.TrimSuffix(path, "/")
1820-
}
1821-
1822-
matchedPaths := []string{}
1823-
for _, entry := range entries {
1824-
if len(matchedPaths) == maxResults {
1825-
break // Limit the number of results to maxResults
1826-
}
1827-
if dirOnly && entry.GetType() != "tree" {
1828-
continue // Skip non-directory entries if dirOnly is true
1829-
}
1830-
entryPath := entry.GetPath()
1831-
if entryPath == "" {
1832-
continue // Skip empty paths
1833-
}
1834-
if strings.HasSuffix(entryPath, path) {
1835-
if entry.GetType() == "tree" {
1836-
entryPath += "/" // Return directories with a trailing slash
1837-
}
1838-
matchedPaths = append(matchedPaths, entryPath)
1839-
}
1840-
}
1841-
return matchedPaths
1842-
}
1843-
1844-
// looksLikeSHA returns true if the string appears to be a Git commit SHA.
1845-
// A SHA is a 40-character hexadecimal string.
1846-
func looksLikeSHA(s string) bool {
1847-
if len(s) != 40 {
1848-
return false
1849-
}
1850-
for _, c := range s {
1851-
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
1852-
return false
1853-
}
1854-
}
1855-
return true
1856-
}
1857-
1858-
// resolveGitReference takes a user-provided ref and sha and resolves them into a
1859-
// definitive commit SHA and its corresponding fully-qualified reference.
1860-
//
1861-
// The resolution logic follows a clear priority:
1862-
//
1863-
// 1. If a specific commit `sha` is provided, it takes precedence and is used directly,
1864-
// and all reference resolution is skipped.
1865-
//
1866-
// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters),
1867-
// it is returned as-is without any API calls or reference resolution.
1868-
//
1869-
// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves
1870-
// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying
1871-
// the following steps in order:
1872-
// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used.
1873-
// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully
1874-
// qualified and used as-is.
1875-
// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is
1876-
// prefixed with "refs/" to make it fully-qualified.
1877-
// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function
1878-
// first attempts to resolve it as a branch ("refs/heads/<ref>"). If that
1879-
// returns a 404 Not Found error, it then attempts to resolve it as a tag
1880-
// ("refs/tags/<ref>").
1881-
//
1882-
// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call
1883-
// is made to fetch that reference's definitive commit SHA.
1884-
//
1885-
// Any unexpected (non-404) errors during the resolution process are returned
1886-
// immediately. All API errors are logged with rich context to aid diagnostics.
1887-
func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) {
1888-
// 1) If SHA explicitly provided, it's the highest priority.
1889-
if sha != "" {
1890-
return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil
1891-
}
1892-
1893-
// 1a) If sha is empty but ref looks like a SHA, return it without changes
1894-
if looksLikeSHA(ref) {
1895-
return &raw.ContentOpts{Ref: "", SHA: ref}, false, nil
1896-
}
1897-
1898-
originalRef := ref // Keep original ref for clearer error messages down the line.
1899-
1900-
// 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format.
1901-
var reference *github.Reference
1902-
var resp *github.Response
1903-
var err error
1904-
var fallbackUsed bool
1905-
1906-
switch {
1907-
case originalRef == "":
1908-
// 2a) If ref is empty, determine the default branch.
1909-
reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo)
1910-
if err != nil {
1911-
return nil, false, err // Error is already wrapped in resolveDefaultBranch.
1912-
}
1913-
ref = reference.GetRef()
1914-
case strings.HasPrefix(originalRef, "refs/"):
1915-
// 2b) Already fully qualified. The reference will be fetched at the end.
1916-
case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"):
1917-
// 2c) Partially qualified. Make it fully qualified.
1918-
ref = "refs/" + originalRef
1919-
default:
1920-
// 2d) It's a short name, so we try to resolve it to either a branch or a tag.
1921-
branchRef := "refs/heads/" + originalRef
1922-
reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef)
1923-
1924-
if err == nil {
1925-
ref = branchRef // It's a branch.
1926-
} else {
1927-
// The branch lookup failed. Check if it was a 404 Not Found error.
1928-
ghErr, isGhErr := err.(*github.ErrorResponse)
1929-
if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound {
1930-
tagRef := "refs/tags/" + originalRef
1931-
reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef)
1932-
if err == nil {
1933-
ref = tagRef // It's a tag.
1934-
} else {
1935-
// The tag lookup also failed. Check if it was a 404 Not Found error.
1936-
ghErr2, isGhErr2 := err.(*github.ErrorResponse)
1937-
if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound {
1938-
if originalRef == "main" {
1939-
reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo)
1940-
if err != nil {
1941-
return nil, false, err // Error is already wrapped in resolveDefaultBranch.
1942-
}
1943-
// Update ref to the actual default branch ref so the note can be generated
1944-
ref = reference.GetRef()
1945-
fallbackUsed = true
1946-
break
1947-
}
1948-
return nil, false, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef)
1949-
}
1950-
1951-
// The tag lookup failed for a different reason.
1952-
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err)
1953-
return nil, false, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err)
1954-
}
1955-
} else {
1956-
// The branch lookup failed for a different reason.
1957-
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err)
1958-
return nil, false, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err)
1959-
}
1960-
}
1961-
}
1962-
1963-
if reference == nil {
1964-
reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref)
1965-
if err != nil {
1966-
if ref == "refs/heads/main" {
1967-
reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo)
1968-
if err != nil {
1969-
return nil, false, err // Error is already wrapped in resolveDefaultBranch.
1970-
}
1971-
// Update ref to the actual default branch ref so the note can be generated
1972-
ref = reference.GetRef()
1973-
fallbackUsed = true
1974-
} else {
1975-
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err)
1976-
return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err)
1977-
}
1978-
}
1979-
}
1980-
1981-
sha = reference.GetObject().GetSHA()
1982-
return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil
1983-
}
1984-
1985-
func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) {
1986-
repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo)
1987-
if err != nil {
1988-
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err)
1989-
return nil, fmt.Errorf("failed to get repository info: %w", err)
1990-
}
1991-
1992-
if resp != nil && resp.Body != nil {
1993-
_ = resp.Body.Close()
1994-
}
1995-
1996-
defaultBranch := repoInfo.GetDefaultBranch()
1997-
1998-
defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch)
1999-
if err != nil {
2000-
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err)
2001-
return nil, fmt.Errorf("failed to get default branch reference: %w", err)
2002-
}
2003-
2004-
if resp != nil && resp.Body != nil {
2005-
defer func() { _ = resp.Body.Close() }()
2006-
}
2007-
2008-
return defaultRef, nil
2009-
}
2010-
20111822
// ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user.
20121823
func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool {
20131824
return NewTool(

0 commit comments

Comments
 (0)