# Issue #994: git pull returns Success when already has all changes

## Issue Summary

**Reporter:** fimdomeio
**Status:** Open
**Label:** bug
**Created:** 2023-07-19
**URL:** https://github.com/pyinfra-dev/pyinfra/issues/994

### Problem Description

When using `git.repo()` to pull from a git repository that is already up-to-date, pyinfra reports "Success" instead of "No changes". The git command output shows "Already up to date." but pyinfra still marks the operation as having made changes.

### Reproduction

```python
git.repo(
    name="pull from neovim repo",
    src="https://github.com/neovim/neovim",
    dest="/home/fimdomeio.linux/src/neovim",
)
```

Debug output shows:
```
[lima] >>> sh -c 'cd /home/fimdomeio.linux/src/neovim && git pull'
[lima] Already up to date.
```

### Expected Behavior

The operation should return "No changes" when `git pull` returns "Already up to date."

---

## Root Cause Analysis

### Current Implementation

The issue is in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/git.py`, specifically in the `repo()` operation (lines 82-177).

The current implementation:

1. When an existing repo is detected (line 147), it unconditionally adds a `git pull` command to the list (lines 155-159):
   ```python
   if pull and not is_tag:
       if rebase:
           git_commands.append("pull --rebase")
       else:
           git_commands.append("pull")
   ```

2. pyinfra determines "success/changed" vs "no change" based on whether any commands were yielded by the operation. Since `git pull` is always yielded when `pull=True` and the repo exists, pyinfra reports "Success" even when no actual changes occurred.

### Maintainer Comment (from Fizzadar)

> The "success" bit is determined as "did run any commands", in the case of git it the "git pull" command. I think this is correct (it did run, it did succeed), pyinfra is thinking on the command level rather than git itself.
>
> We could, I think, use git fetch to pre-check for changes and only pull if so which would resolve this, though I'm not sure in reality if this is a problem?

---

## Proposed Fix

### Approach: Use Git Commit Hash to Detect Changes

The recommended approach is to compare the local commit hash with the remote tracking branch before pulling. This avoids the extra network round-trip of `git fetch` just for checking.

### Implementation Details

#### 1. Add New Git Facts

Add a new fact to get the current HEAD commit SHA in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/git.py`:

```python
class GitHeadSha(GitFactBase):
    """Returns the current HEAD commit SHA."""

    @override
    def command(self, repo) -> str:
        return "! test -d {0} || (cd {0} && git rev-parse HEAD)".format(repo)

    @override
    def process(self, output):
        if output:
            return output[0].strip()
        return None


class GitRemoteHeadSha(GitFactBase):
    """Returns the remote tracking branch HEAD commit SHA after fetching."""

    @override
    def command(self, repo, remote="origin", branch=None) -> str:
        # Fetch and get the remote ref; branch defaults to current branch
        if branch:
            return (
                "! test -d {0} || (cd {0} && git fetch {1} {2} && "
                "git rev-parse {1}/{2})".format(repo, remote, branch)
            )
        return (
            "! test -d {0} || (cd {0} && git fetch {1} && "
            "git rev-parse @{{u}} 2>/dev/null || git rev-parse {1}/HEAD 2>/dev/null)".format(repo, remote)
        )

    @override
    def process(self, output):
        if output:
            return output[-1].strip()  # Last line contains the SHA
        return None
```

#### 2. Modify the `repo()` Operation

Update `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/git.py` to check if pull is needed:

```python
# In the "Ensuring existing repo" section (around line 147)
else:
    is_tag = False
    if branch and host.get_fact(GitBranch, repo=dest) != branch:
        git_commands.append("fetch")  # fetch to ensure we have the branch locally
        git_commands.append("checkout {0}".format(branch))
    if branch and branch in (host.get_fact(GitTag, repo=dest) or []):
        git_commands.append("checkout {0}".format(branch))
        is_tag = True
    if pull and not is_tag:
        # Check if there are upstream changes before pulling
        local_sha = host.get_fact(GitHeadSha, repo=dest)
        remote_sha = host.get_fact(GitRemoteHeadSha, repo=dest)

        if local_sha != remote_sha:
            if rebase:
                git_commands.append("pull --rebase")
            else:
                git_commands.append("pull")
        else:
            host.noop("repository is already up to date")
```

### Alternative Simpler Approach

A simpler approach that doesn't require new facts would be to compare commits before and after fetch:

```python
# In the "Ensuring existing repo" section
else:
    is_tag = False
    needs_fetch = False

    if branch and host.get_fact(GitBranch, repo=dest) != branch:
        needs_fetch = True
        git_commands.append("fetch")
        git_commands.append("checkout {0}".format(branch))
    if branch and branch in (host.get_fact(GitTag, repo=dest) or []):
        git_commands.append("checkout {0}".format(branch))
        is_tag = True
    if pull and not is_tag:
        # Get current local HEAD
        local_sha = host.get_fact(GitHeadSha, repo=dest)

        # Fetch to update remote refs if not already fetched
        if not needs_fetch:
            git_commands.append("fetch")

        # Get remote tracking branch SHA
        tracking_branch = host.get_fact(GitTrackingBranch, repo=dest)
        if tracking_branch:
            remote_sha = host.get_fact(GitRefSha, repo=dest, ref=tracking_branch)
            if local_sha != remote_sha:
                if rebase:
                    git_commands.append("pull --rebase")
                else:
                    git_commands.append("pull")
            else:
                host.noop("repository is already up to date")
        else:
            # No tracking branch, fall back to always pulling
            if rebase:
                git_commands.append("pull --rebase")
            else:
                git_commands.append("pull")
```

---

## Files to Modify

1. **`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/git.py`**
   - Add `GitHeadSha` fact class
   - Add `GitRemoteHeadSha` or `GitRefSha` fact class

2. **`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/git.py`**
   - Import new facts
   - Modify `repo()` operation to compare SHAs before pulling
   - Add `host.noop()` call when repository is already up to date

3. **Tests to add/update:**
   - `/Users/nick/Dev/Webroot/pyinfra/src/tests/facts/git.py` - Add tests for new facts
   - `/Users/nick/Dev/Webroot/pyinfra/src/tests/operations/test_git.py` - Add tests for no-change scenario

---

## Trade-offs and Considerations

### Advantages of the Fix

1. **Accurate status reporting:** Operations show "No changes" when nothing changed
2. **Better visibility:** Users can distinguish between "ran and changed something" vs "ran and nothing needed"
3. **Consistent behavior:** Matches behavior of other pyinfra operations that pre-check state

### Potential Downsides

1. **Extra network request:** The `git fetch` for checking adds latency
2. **More complexity:** Additional fact gathering and logic
3. **Edge cases:** May behave differently with shallow clones or specific git configurations

### Backward Compatibility

This change should be backward compatible:
- The operation signature remains the same
- Behavior only changes from "Success" to "No changes" when appropriate
- No breaking changes to existing deployments

---

## Related Issues

- This pattern of unconditional command execution appears in the `worktree()` operation as well (lines 367-380), which has the same issue when pulling an existing worktree.
