fix(pull): unborn branch misread as dirty, diff-index vs unresolvable HEAD refuses the first sync #373

Closed
opened 2026-08-05 15:59:51 +00:00 by hexajon · 0 comments
hexajon commented 2026-08-05 15:59:51 +00:00 (Migrated from codeberg.org)

What happens

madtea pull refuses to pull into a freshly-cloned repo whose local branch is still unborn (zero commits) while it already tracks a populated origin/main:

pull refused: you have uncommitted changes on tracked files.
  git stash            # set them aside, then re-pull and: git stash pop
  git commit -am <msg>   # or commit them first, then re-pull

The tree is definitionally clean: the branch has no commits, nothing is staged or modified, no untracked files. The refusal names zero files, and that emptiness is the tell.

Root cause

Pull gates on the dirty-tree precheck at internal/service/git/pull.go:133:

if pullHasUncommittedChanges(in.Dir) {
    return nil, fmt.Errorf("%s", uncommittedGuidance(pullUncommittedFiles(in.Dir)))
}

HasUncommittedChangesDir (internal/git/state.go:573):

func HasUncommittedChangesDir(dir string) bool {
    _, err := RunGitDir(stateCtx(), dir, "diff-index", "--quiet", "HEAD", "--")
    return err != nil
}

This relies on git diff-index --quiet HEAD exiting 1 to signal "dirty". But on an unborn branch HEAD does not resolve, so the command exits 128 (fatal: ambiguous argument 'HEAD': unknown revision), a genuine error rather than the exit-1 diff signal. err != nil collapses both into "dirty", so the guard reports a repo with no commits as having uncommitted changes.

UncommittedFilesDir (internal/git/state.go:586) hits the same diff-index HEAD error and returns nil, which is why the refusal cannot name any files.

Reproduce

git init r && cd r
git remote add origin <repo-with-commits>
git fetch origin
# local 'main' is now unborn but tracks a populated origin/main
madtea pull      # refused as "uncommitted changes", nothing is dirty

Workaround: git merge --ff-only origin/main (the ref is already fetched) brings the tree in; the next madtea pull works normally once HEAD exists. This is exactly the "first checkout into sync" path, so the refusal blocks a legitimate, common state.

Fix direction

Distinguish an unborn HEAD (clean: no tracked change is possible against a nonexistent HEAD) from a real diff before the blanket err != nil, e.g. short-circuit when HEAD does not verify:

if _, err := RunGitDir(stateCtx(), dir, "rev-parse", "--verify", "--quiet", "HEAD"); err != nil {
    return false // unborn: nothing committed to diff against
}

Caveat: HasUncommittedChangesDir is shared by the branch-create and worktree "stranger's uncommitted work" guards (internal/mcp/tools_localgit.go:184, internal/service/git/worktree.go:34), and the comment already at tools_localgit.go:178 notes this unborn-HEAD error case. An unborn branch with a staged index is a real (if rare) dirty state those guards may still want to catch, so treating "unborn implies clean" is too blunt if it drops that. A safer shape: when HEAD is unborn, fall back to git diff --cached --quiet (which diffs the index against the empty tree and correctly reports staged-but-uncommitted files) instead of treating the diff-index HEAD error as dirty. Whatever the approach, the pull precheck must allow the first fast-forward into an unborn checkout.

## What happens `madtea pull` refuses to pull into a freshly-cloned repo whose local branch is still **unborn** (zero commits) while it already tracks a populated `origin/main`: ``` pull refused: you have uncommitted changes on tracked files. git stash # set them aside, then re-pull and: git stash pop git commit -am <msg> # or commit them first, then re-pull ``` The tree is definitionally clean: the branch has no commits, nothing is staged or modified, no untracked files. The refusal names **zero** files, and that emptiness is the tell. ## Root cause `Pull` gates on the dirty-tree precheck at `internal/service/git/pull.go:133`: ```go if pullHasUncommittedChanges(in.Dir) { return nil, fmt.Errorf("%s", uncommittedGuidance(pullUncommittedFiles(in.Dir))) } ``` `HasUncommittedChangesDir` (`internal/git/state.go:573`): ```go func HasUncommittedChangesDir(dir string) bool { _, err := RunGitDir(stateCtx(), dir, "diff-index", "--quiet", "HEAD", "--") return err != nil } ``` This relies on `git diff-index --quiet HEAD` exiting **1** to signal "dirty". But on an unborn branch `HEAD` does not resolve, so the command exits **128** (`fatal: ambiguous argument 'HEAD': unknown revision`), a genuine error rather than the exit-1 diff signal. `err != nil` collapses both into "dirty", so the guard reports a repo with no commits as having uncommitted changes. `UncommittedFilesDir` (`internal/git/state.go:586`) hits the same `diff-index HEAD` error and returns nil, which is why the refusal cannot name any files. ## Reproduce ``` git init r && cd r git remote add origin <repo-with-commits> git fetch origin # local 'main' is now unborn but tracks a populated origin/main madtea pull # refused as "uncommitted changes", nothing is dirty ``` Workaround: `git merge --ff-only origin/main` (the ref is already fetched) brings the tree in; the next `madtea pull` works normally once HEAD exists. This is exactly the "first checkout into sync" path, so the refusal blocks a legitimate, common state. ## Fix direction Distinguish an unborn HEAD (clean: no tracked change is possible against a nonexistent HEAD) from a real diff before the blanket `err != nil`, e.g. short-circuit when HEAD does not verify: ```go if _, err := RunGitDir(stateCtx(), dir, "rev-parse", "--verify", "--quiet", "HEAD"); err != nil { return false // unborn: nothing committed to diff against } ``` Caveat: `HasUncommittedChangesDir` is shared by the branch-create and worktree "stranger's uncommitted work" guards (`internal/mcp/tools_localgit.go:184`, `internal/service/git/worktree.go:34`), and the comment already at `tools_localgit.go:178` notes this unborn-HEAD error case. An unborn branch with a *staged* index is a real (if rare) dirty state those guards may still want to catch, so treating "unborn implies clean" is too blunt if it drops that. A safer shape: when HEAD is unborn, fall back to `git diff --cached --quiet` (which diffs the index against the empty tree and correctly reports staged-but-uncommitted files) instead of treating the `diff-index HEAD` error as dirty. Whatever the approach, the pull precheck must allow the first fast-forward into an unborn checkout.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
sixfold-space/madtea#373
No description provided.