Skip to content
Open
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
9 changes: 9 additions & 0 deletions go/cmd/gitter/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,20 @@ func isRefNotFoundError(err error) bool {
return errContainsAny(err,
"not found or invalid",
"failed to resolve target ref",
"failed to resolve target branch",
"failed to run git rev-parse",
"ref cannot be empty",
)
}

// isCommitNotAncestorError returns true if last_scan_commit is not found in the repository or is not an ancestor of HEAD.
func isCommitNotAncestorError(err error) bool {
return errContainsAny(err,
"not an ancestor",
"failed to resolve last_scan_commit",
)
}

// isFileNotFoundError returns true if a file path does not exist at the requested commit.
func isFileNotFoundError(err error) bool {
return errContainsAny(err,
Expand Down
23 changes: 12 additions & 11 deletions go/cmd/gitter/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,19 @@ func prepareCmd(ctx context.Context, dir string, env []string, name string, args
}

// runCmd executes a command with context cancellation handled by sending SIGINT.
// It logs cancellation errors separately as requested.
func runCmd(ctx context.Context, dir string, env []string, name string, args ...string) error {
// It logs cancellation errors separately as requested
func runCmd(ctx context.Context, dir string, env []string, name string, args ...string) ([]byte, error) {
cmd := prepareCmd(ctx, dir, env, name, args...)
out, err := cmd.CombinedOutput()

if err != nil {
if ctx.Err() != nil {
// Log separately if cancelled
logger.DebugContext(ctx, "Command cancelled", slog.String("cmd", name), slog.Any("err", ctx.Err()))
return fmt.Errorf("command %s cancelled: %w", name, ctx.Err())
return out, fmt.Errorf("command %s cancelled: %w", name, ctx.Err())
}

return fmt.Errorf("command %s failed: %w, output: %s", name, err, out)
return out, fmt.Errorf("command %s failed: %w, output: %s", name, err, out)
}

logger.DebugContext(ctx, "Git command executed",
Expand All @@ -63,12 +63,13 @@ func runCmd(ctx context.Context, dir string, env []string, name string, args ...
slog.String("output", string(out)),
)

return nil
return out, nil
}

// cloneRepo clones a git repository into repoPath.
func cloneRepo(ctx context.Context, repoURL string, repoPath string) error {
return runCmd(ctx, "", []string{"GIT_TERMINAL_PROMPT=0"}, "git", "clone", "--", repoURL, repoPath)
_, err := runCmd(ctx, "", []string{"GIT_TERMINAL_PROMPT=0"}, "git", "clone", "--", repoURL, repoPath)
return err
}

// Attempt to recover from git fetch errors
Expand All @@ -82,7 +83,7 @@ func attemptGitRecovery(ctx context.Context, repoPath string, err error) bool {
// We can try removing stale remote-tracking branches and retry
if isRefConflictError(err) {
logger.WarnContext(ctx, "Ref conflict detected, running git remote prune origin")
if err := runCmd(ctx, repoPath, nil, "git", "remote", "prune", "origin"); err != nil {
if _, err := runCmd(ctx, repoPath, nil, "git", "remote", "prune", "origin"); err != nil {
logger.WarnContext(ctx, "Failed to prune origin", slog.Any("err", err))
return false
}
Expand All @@ -101,13 +102,13 @@ func attemptGitRecovery(ctx context.Context, repoPath string, err error) bool {

// fetchRepo fetches remote origin and updates origin/HEAD to the remote's default branch
func fetchRepo(ctx context.Context, repoPath string) error {
err := runCmd(ctx, repoPath, nil, "git", "fetch", "origin")
_, err := runCmd(ctx, repoPath, nil, "git", "fetch", "origin")
if err != nil {
return fmt.Errorf("git fetch failed: %w", err)
}

// Make sure origin/HEAD points to the latest default branch from remotes
err = runCmd(ctx, repoPath, nil, "git", "remote", "set-head", "origin", "--auto")
_, err = runCmd(ctx, repoPath, nil, "git", "remote", "set-head", "origin", "--auto")
if err != nil {
logger.WarnContext(ctx, "git remote set-head failed", slog.Any("err", err))
}
Expand Down Expand Up @@ -297,14 +298,14 @@ func ArchiveRepo(ctx context.Context, repoURL string) ([]byte, error) {
startArchive := time.Now()

// Reset working tree to origin/HEAD before creating archive
if err := runCmd(ctx, repoPath, nil, "git", "reset", "--hard", "origin/HEAD"); err != nil {
if _, err := runCmd(ctx, repoPath, nil, "git", "reset", "--hard", "origin/HEAD"); err != nil {
return nil, fmt.Errorf("git reset failed: %w", err)
}

// Archive
// tar --zstd -cf <archivePath> -C "<gitStorePath>/<repoDirName>" .
// using -C to archive the relative path so it unzips nicely
err := runCmd(ctx, "", nil, "tar", "--zstd", "-cf", archivePath, "-C", filepath.Join(gitStorePath, repoDirName), ".")
_, err := runCmd(ctx, "", nil, "tar", "--zstd", "-cf", archivePath, "-C", filepath.Join(gitStorePath, repoDirName), ".")
if err != nil {
return nil, fmt.Errorf("tar zstd failed: %w", err)
}
Expand Down
12 changes: 6 additions & 6 deletions go/cmd/gitter/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ func TestRefreshRepo_DontRecloneOnRemoteError(t *testing.T) {
if err := os.MkdirAll(repoPath, 0755); err != nil {
t.Fatalf("failed to create repo dir: %v", err)
}
if err := runCmd(ctx, repoPath, nil, "git", "init"); err != nil {
if _, err := runCmd(ctx, repoPath, nil, "git", "init"); err != nil {
t.Fatalf("git init failed: %v", err)
}
// Configure dummy git user
_ = runCmd(ctx, repoPath, nil, "git", "config", "user.name", "Test")
_ = runCmd(ctx, repoPath, nil, "git", "config", "user.email", "test@example.com")
_, _ = runCmd(ctx, repoPath, nil, "git", "config", "user.name", "Test")
_, _ = runCmd(ctx, repoPath, nil, "git", "config", "user.email", "test@example.com")
// Add remote origin pointing to an unreachable port (connection refused)
if err := runCmd(ctx, repoPath, nil, "git", "remote", "add", "origin", "https://127.0.0.1:59999/repo.git"); err != nil {
if _, err := runCmd(ctx, repoPath, nil, "git", "remote", "add", "origin", "https://127.0.0.1:59999/repo.git"); err != nil {
t.Fatalf("git remote add failed: %v", err)
}

Expand All @@ -63,10 +63,10 @@ func TestRefreshRepo_DontRecloneOnRemoteError(t *testing.T) {
if err := os.WriteFile(dummyFile, []byte("hello"), 0600); err != nil {
t.Fatalf("write file failed: %v", err)
}
if err := runCmd(ctx, repoPath, nil, "git", "add", "dummy.txt"); err != nil {
if _, err := runCmd(ctx, repoPath, nil, "git", "add", "dummy.txt"); err != nil {
t.Fatalf("git add failed: %v", err)
}
if err := runCmd(ctx, repoPath, nil, "git", "commit", "-m", "initial commit"); err != nil {
if _, err := runCmd(ctx, repoPath, nil, "git", "commit", "-m", "initial commit"); err != nil {
t.Fatalf("git commit failed: %v", err)
}

Expand Down
109 changes: 109 additions & 0 deletions go/cmd/gitter/gitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"golang.org/x/sync/singleflight"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"

pb "github.com/google/osv.dev/go/internal/gitter/pb/repository"
)
Expand All @@ -56,6 +57,7 @@ var endpointHandlers = map[string]http.HandlerFunc{
"POST /affected-commits": affectedCommitsHandler,
"POST /file-diffs": fileDiffsHandler,
"POST /file-content": fileContentHandler,
"POST /commit-diffs": commitDiffsHandler,
}

var (
Expand Down Expand Up @@ -965,3 +967,110 @@ func fileContentHandler(w http.ResponseWriter, req *http.Request) {
http.Error(w, fmt.Sprintf("Error writing file content response: %v", err), statusCode)
}
}

func commitDiffsHandler(w http.ResponseWriter, req *http.Request) {
start := time.Now()
statusCode := http.StatusOK
ctx := req.Context()
defer func() { logRequestCompletion(ctx, "/commit-diffs", start, statusCode) }()

body := &pb.CommitDiffsRequest{}
if err := unmarshalRequest(req, body); err != nil {
statusCode = http.StatusBadRequest
http.Error(w, fmt.Sprintf("Error unmarshaling request: %v", err), statusCode)

return
}

repoURL, err := prepareURL(req, body.GetUrl())
if err != nil {
statusCode = http.StatusBadRequest
http.Error(w, err.Error(), statusCode)

return
}

lastScanCommit := strings.TrimSpace(body.GetLastScanCommit())
branch := strings.TrimSpace(body.GetBranch())
var lastScanTime time.Time
if body.GetLastScanTime() != nil {
lastScanTime = body.GetLastScanTime().AsTime()
}

// Require at least one boundary (last_scan_commit or last_scan_time).
if lastScanCommit == "" && lastScanTime.IsZero() {
statusCode = http.StatusBadRequest
http.Error(w, "missing last_scan_commit and last_scan_time (must provide at least one)", statusCode)

return
}

ctx = context.WithValue(ctx, urlKey, repoURL)
logger.DebugContext(ctx, "Received request: /commit-diffs",
slog.String("last_scan_commit", lastScanCommit),
slog.String("branch", branch),
slog.Time("last_scan_time", lastScanTime),
slog.Bool("newest_first", body.GetNewestFirst()),
)

// Always force update to get latest commits from the remote
repo, err := SyncRepoOnDisk(ctx, repoURL, FetchOptions{ForceUpdate: true, SkipReqConcurrencySemaphore: false})
if err != nil {
statusCode = errorToHTTPStatusCode(err)
cacheInvalidRepo(repoURL, statusCode)
http.Error(w, fmt.Sprintf("Error getting repo: %v", err), statusCode)

return
}

resolvedBranch, headCommit, commits, err := repo.ListCommits(ctx, branch, lastScanCommit, lastScanTime, body.GetNewestFirst(), body.GetIncludePaths(), body.GetExcludePaths())
if err != nil {
// Distinguish missing refs - 404 Not Found (e.g. the commit hash is not a valid ancestor of HEAD - likely from git amend)
// From generic issues - 500
if isRefNotFoundError(err) || isCommitNotAncestorError(err) {
statusCode = http.StatusNotFound
http.Error(w, fmt.Sprintf("Commit or branch not found: %v", err), statusCode)

return
}
logger.ErrorContext(ctx, "Error listing commits", slog.Any("error", err))
statusCode = http.StatusInternalServerError
http.Error(w, fmt.Sprintf("Error listing commits: %v", err), statusCode)

return
}

pbCommits := make([]*pb.CommitDiff, 0, len(commits))
for _, c := range commits {
filesChanged := make([]*pb.FileChange, 0, len(c.FilesChanged))
for _, f := range c.FilesChanged {
filesChanged = append(filesChanged, &pb.FileChange{
FromPath: f.From,
ToPath: f.To,
})
}
pbCommits = append(pbCommits, &pb.CommitDiff{
Commit: c.Commit,
Timestamp: timestamppb.New(c.Timestamp),
Message: c.Message,
Patch: c.Patch,
FilesChanged: filesChanged,
PatchTruncated: c.PatchTruncated,
})
}

resp := &pb.CommitDiffsResponse{
Url: body.GetUrl(),
Branch: resolvedBranch,
HeadCommit: headCommit,
//nolint:gosec // G115: len(commits) should safely fit in int32 (max is 2.14 billion)
NumCommits: int32(len(commits)),
Commits: pbCommits,
}

if err := writeResponse(w, req, resp); err != nil {
logger.ErrorContext(ctx, "Error writing commit diffs response", slog.Any("error", err))
statusCode = http.StatusInternalServerError
http.Error(w, fmt.Sprintf("Error writing commit diffs response: %v", err), statusCode)
}
}
Loading
Loading