Skip to content

fix: treat ref=HEAD on empty repo as empty tree#3158

Merged
hieptl merged 2 commits into
mainfrom
hieptl/app-1630
May 8, 2026
Merged

fix: treat ref=HEAD on empty repo as empty tree#3158
hieptl merged 2 commits into
mainfrom
hieptl/app-1630

Conversation

@hieptl

@hieptl hieptl commented May 8, 2026

Copy link
Copy Markdown
Contributor
  • A human has tested these changes.

Why

Problem

When a user clicks Create New Conversation in agent-server-gui (localhost:3001) and opens the Changes tab, the tab renders the raw shell command in a red error banner:

Failed to get git changes: HTTP request failed (400 Bad Request):
{"detail":"Git command failed: git --no-pager rev-parse --verify 'HEAD^{commit}'"}

Reproduction

  1. Start the agent-server backend (http://127.0.0.1:8000) and the GUI dev server (http://localhost:3001).
  2. In the GUI, click Create New Conversation.
  3. On the conversation page, click the Changes tab.
  4. Observe the 400 error banner.

The failing request:

GET http://127.0.0.1:8000/api/git/changes
    ?path=/Users/<user>/.openhands/agent-canvas/workspaces/<id>
    &ref=HEAD

Root cause

The runtime git inits the new workspace, but does not commit anything — so HEAD does not resolve. The Changes tab requests ?ref=HEAD to get git-status semantics (agent-server-gui/src/api/git-service/agent-server-git-service.api.ts:74). On the backend, the SDK's get_valid_ref() (software-agent-sdk/openhands-sdk/openhands/sdk/git/utils.py) runs git rev-parse --verify 'HEAD^{commit}' unconditionally on the override branch, which exits non-zero. run_git_command raises GitCommandError → the router translates that to HTTP 400.

The default-ref branch of the same function already handles empty repos via _repo_has_commits() + GIT_EMPTY_TREE_HASH. The override branch was simply missing that check.

Expected behavior

Opening the Changes tab on a brand-new conversation should render the empty/no-changes state (or any untracked files as added) with no error banner.

Acceptance criteria

  • GET /api/git/changes?path=<empty-repo>&ref=HEAD returns 200 with a list (empty or untracked-as-ADDED).
  • GET /api/git/changes?path=<repo-with-commits>&ref=HEAD keeps existing git-status semantics (working tree vs HEAD).
  • GET /api/git/changes?path=<repo>&ref=<bad-ref> still returns 400 with GitCommandError so genuine bad refs stay diagnosable.
  • Changes tab no longer shows the git rev-parse --verify 'HEAD^{commit}' banner on a fresh conversation.
  • New tests cover the empty-repo ref=HEAD case at both the SDK and router layers.

Summary

  • Fix HTTP 400 on /api/git/changes?ref=HEAD when the workspace is a freshly-init'd git repo with no commits yet.
  • Mirror the existing default-ref behavior: empty repo → GIT_EMPTY_TREE_HASH, so untracked files render as ADDED.
  • Surgical: one if-block in get_valid_ref(). No router changes, no signature changes, no frontend changes.

Symptom

The Changes tab in agent-server-gui shows this banner the first time you open a fresh conversation:

Failed to get git changes: HTTP request failed (400 Bad Request):
{"detail":"Git command failed: git --no-pager rev-parse --verify 'HEAD^{commit}'"}

Root cause

# openhands-sdk/openhands/sdk/git/utils.py — before
def get_valid_ref(repo_dir, override=None):
    if override is not None:
        return run_git_command(
            ["git", "--no-pager", "rev-parse", "--verify", f"{override}^{{commit}}"],
            repo_dir,
        )
    # ...no-override path checks _repo_has_commits() and falls back to
    #    GIT_EMPTY_TREE_HASH for empty repos.

The override branch never checked _repo_has_commits(). The Changes tab passes ref: "HEAD" deliberately (git-status semantics), so every brand-new conversation hit the failing path.

Fix

# openhands-sdk/openhands/sdk/git/utils.py — after
def get_valid_ref(repo_dir, override=None):
    if override is not None:
        # `HEAD` doesn't resolve in a freshly-init'd repo with no commits.
        # Treat it the same as the auto-detected path (empty tree) so the
        # Changes tab on a brand-new workspace renders untracked files as
        # added instead of erroring out.
        if override == "HEAD" and not _repo_has_commits(repo_dir):
            logger.debug(
                "Override 'HEAD' requested but repo has no commits; "
                "using empty tree reference"
            )
            return GIT_EMPTY_TREE_HASH
        return run_git_command(
            ["git", "--no-pager", "rev-parse", "--verify", f"{override}^{{commit}}"],
            repo_dir,
        )
    # ...

Narrow on purpose: only canonical HEAD is intercepted. A bad commit hash, a typoed branch name, or any other override still raises GitCommandError → HTTP 400, so genuine bad refs stay diagnostic.

Why not fix the frontend instead?

agent-server-gui passes ref: "HEAD" deliberately to get git-status semantics (working tree vs HEAD). When the workspace has commits, that request is correct and useful. The bug is the backend's failure to handle the "no HEAD yet" edge case — fixing it at the source also covers any other client (curl, future SDKs) that passes ?ref=HEAD.

Why not catch GitCommandError in the router?

That would mask real failures (bad ref, broken repo) as "no changes" and lose the diagnostic value of the 400 path. The fix targets the one well-known empty-repo case, not blanket-suppress git errors.

How to Test

  1. Start the agent-server backend (http://127.0.0.1:8000) and the GUI dev server (http://localhost:3001).
  2. In the GUI, click Create New Conversation.
  3. On the conversation page, click the Changes tab.
  4. Observe the 400 error banner.

Demo Video/Screenshots

image

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:8955993-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-8955993-python \
  ghcr.io/openhands/agent-server:8955993-python

All tags pushed for this build

ghcr.io/openhands/agent-server:8955993-golang-amd64
ghcr.io/openhands/agent-server:8955993-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:8955993-golang-arm64
ghcr.io/openhands/agent-server:8955993-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:8955993-java-amd64
ghcr.io/openhands/agent-server:8955993-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:8955993-java-arm64
ghcr.io/openhands/agent-server:8955993-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:8955993-python-amd64
ghcr.io/openhands/agent-server:8955993-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:8955993-python-arm64
ghcr.io/openhands/agent-server:8955993-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:8955993-golang
ghcr.io/openhands/agent-server:8955993-java
ghcr.io/openhands/agent-server:8955993-python

About Multi-Architecture Support

  • Each variant tag (e.g., 8955993-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 8955993-python-amd64) are also available if needed

@hieptl hieptl self-assigned this May 8, 2026
@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

PR Artifacts Cleaned Up

The .pr/ directory has been automatically removed.

@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean bug fix that mirrors existing empty-repo handling pattern. Appropriately surgical - only intercepts the well-known HEAD case while letting other bad refs fail diagnostically.

Risk Assessment: 🟢 LOW - Edge case fix with good test coverage, no agent behavior changes.

@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/git
   utils.py1212777%72–74, 99–101, 163–164, 171–176, 181–182, 192–197, 207–209, 237, 347
TOTAL26281616776% 

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

Verified the fix resolves the HTTP 400 error when opening the Changes tab on a fresh conversation with no commits.

Does this PR achieve its stated goal?

Yes. The PR successfully fixes the HTTP 400 error that occurred when requesting GET /api/git/changes?ref=HEAD on a freshly-initialized git repo with no commits. The fix correctly handles the empty-repo edge case by returning GIT_EMPTY_TREE_HASH, which allows untracked files to render as ADDED instead of raising a GitCommandError. I verified this by reproducing the bug on the main branch and confirming it's fixed on the PR branch, testing both the SDK layer and the API router layer.

Phase Result
Environment Setup ✅ Dependencies synced with uv sync --frozen
CI Status ✅ Key checks passing: Python API, REST API, pre-commit, agent-server-tests, sdk-tests (pending), tools-tests, workspace-tests
Functional Verification ✅ Reproduced bug, verified fix, tested edge cases
Functional Verification

Test 1: Reproduce the bug (main branch, without fix)

Step 1 — Reproduce the bug without the fix:

Checked out origin/main and ran a test that simulates the exact scenario: freshly-initialized git repo, no commits, request ref=HEAD.

$ git checkout origin/main
$ uv run python /tmp/test_git_changes.py

Output:

=== Testing in: /tmp/tmpmee1w_cm ===

Attempting to get git changes with ref=HEAD...
{"levelname": "ERROR", "message": "Git command failed: git --no-pager rev-parse --verify 'HEAD^{commit}'. Exit code: 128. Stderr: fatal: Needed a single revision\n"}
❌ FAILED: GitCommandError raised: Git command failed: git --no-pager rev-parse --verify 'HEAD^{commit}'

Interpretation: This confirms the bug exists. When ref=HEAD is requested on an empty repo, the SDK tries to resolve HEAD^{commit} which fails because HEAD doesn't exist yet. This error bubbles up as HTTP 400 in the agent-server router.


Step 2 — Apply the PR's changes:

Checked out the PR branch hieptl/app-1630.

$ git checkout hieptl/app-1630

Step 3 — Re-run with the fix in place:

$ uv run python /tmp/test_git_changes.py

Output:

=== Testing in: /tmp/tmpeh5i6aok ===

Attempting to get git changes with ref=HEAD...
{"levelname": "INFO", "message": "Found 1 total git changes in /tmp/tmpeh5i6aok"}
✅ SUCCESS: Got changes: [GitChange(status=<GitChangeStatus.ADDED: 'ADDED'>, path=PosixPath('untracked.txt'))]

Interpretation: The fix works. The get_valid_ref() function now intercepts ref=HEAD on empty repos and returns GIT_EMPTY_TREE_HASH, which allows the git diff to succeed. Untracked files correctly appear as ADDED status.


Test 2: SDK test passes

Ran the new test test_get_changes_in_repo_ref_head_on_empty_repo_returns_untracked_as_added:

$ uv run pytest tests/sdk/git/test_git_changes.py::test_get_changes_in_repo_ref_head_on_empty_repo_returns_untracked_as_added -v

Result: PASSED in 0.28s


Test 3: API router test passes

Ran the new test test_git_changes_query_param_ref_head_on_empty_repo_returns_200:

$ uv run pytest tests/agent_server/test_git_router.py::test_git_changes_query_param_ref_head_on_empty_repo_returns_200 -v

Result: PASSED in 1.09s

This end-to-end test verifies that GET /api/git/changes?path=<empty-repo>&ref=HEAD returns HTTP 200 with the correct JSON response:

[{"status": "ADDED", "path": "untracked.txt"}]

Test 4: Edge cases verified

Ran additional tests to ensure the fix doesn't break other scenarios:

  1. Normal repo with commits, ref=HEAD: ✅ Still works correctly
  2. Empty repo with invalid ref (e.g., nonexistent-branch): ✅ Still raises GitCommandError (correct behavior for bad refs)
  3. Empty repo without explicit ref parameter: ✅ Works correctly (uses auto-detected ref)

Output:

=== Test 1: Normal repo with commits, ref=HEAD ===
✅ SUCCESS: Got changes: [GitChange(status=<GitChangeStatus.ADDED: 'ADDED'>, path=PosixPath('untracked.txt'))]

=== Test 2: Empty repo with invalid ref ===
✅ SUCCESS: Correctly raised GitCommandError: Git command failed: git --no-pager rev-parse --verify 'nonexistent-branch^{commit}'

=== Test 3: Empty repo without ref parameter ===
✅ SUCCESS: Got changes: [GitChange(status=<GitChangeStatus.ADDED: 'ADDED'>, path=PosixPath('untracked.txt'))]

=== Summary: 3/3 tests passed ===

Interpretation: The fix is surgical and correctly scoped. It only intercepts the specific case of ref="HEAD" on empty repos. All other scenarios continue to work as before, including proper error handling for genuinely invalid refs.

Issues Found

None.

@hieptl hieptl merged commit f59724a into main May 8, 2026
34 checks passed
@hieptl hieptl deleted the hieptl/app-1630 branch May 8, 2026 19:12
StressTestor pushed a commit to StressTestor/software-agent-sdk that referenced this pull request Jun 1, 2026
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants