Repository: pyinfra-dev/pyinfra

Issue:
git.repo fails when a tag is checked out
## Describe the bug

Running this twice:
```
headscale_admin_git_repo = 'https://github.com/GoodiesHQ/headscale-admin'
headscale_admin_code_path = '/srv/headscale_admin'
headscale_admin_git_branch = 'v0.25.6'

# Set up headscale-admin GUI
git.repo(
  name='Clone Headscale-admin git repository',
  src=headscale_admin_git_repo,
  dest=headscale_admin_code_path,
  branch=headscale_admin_git_branch,
) 
```

When the repo is cloned for the first time using `git.repo` it works. On the second run we have the error below:

```
--> Starting operation: Clone Headscale-admin git repository 
    [wireguard-ui-trial]      You are not currently on a branch.
    [wireguard-ui-trial]      Please specify which branch you want to merge with.
    [wireguard-ui-trial]      See git-pull(1) for details.
    [wireguard-ui-trial]      
    [wireguard-ui-trial]          git pull <remote> <branch>
    [wireguard-ui-trial]      
    [wireguard-ui-trial]      Error: executed 0 commands

```

## To Reproduce
Steps to reproduce the behavior, please include where possible:

+ Operation code & usage
+ Target system information
+ Example using the `@docker` connector (helps isolate the problem)

```
$ pip list |grep pyi
pyinfra             3.2

```

command:
```
$ pyinfra ./inventory-headscale.py ./deploy_headscale.py -y -vv --limit wireguard-ui-trial
[other operations]
```

First time:
```
--> Starting operation: Clone Headscale-admin git repository 
[wireguard-ui-trial]      >>> sh -c '! (test -e /srv/headscale_admin || test -L /srv/headscale_admin ) || ( stat -c '"'"'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"'"' /srv/headscale_admin 2> /dev/null || stat -f '"'"'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"'"' /srv/headscale_admin )'
    [wireguard-ui-trial]      Loaded fact files.Directory (path=/srv/headscale_admin)
[wireguard-ui-trial]      >>> sh -c 'mkdir -p /srv/headscale_admin'
[wireguard-ui-trial]      >>> sh -c '! (test -e /srv/headscale_admin/.git || test -L /srv/headscale_admin/.git ) || ( stat -c '"'"'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"'"' /srv/headscale_admin/.git 2> /dev/null || stat -f '"'"'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"'"' /srv/headscale_admin/.git )'
    [wireguard-ui-trial]      Loaded fact files.Directory (path=/srv/headscale_admin/.git)
[wireguard-ui-trial]      >>> sh -c 'cd /srv/headscale_admin && git clone https://github.com/GoodiesHQ/headscale-admin --branch v0.25.6 .'
    [wireguard-ui-trial]      Success
```

second time:
```
--> Starting operation: Clone Headscale-admin git repository 
[wireguard-ui-trial]      >>> sh -c '! (test -e /srv/headscale_admin || test -L /srv/headscale_admin ) || ( stat -c '"'"'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"'"' /srv/headscale_admin 2> /dev/null || stat -f '"'"'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"'"' /srv/headscale_admin )'
    [wireguard-ui-trial]      Loaded fact files.Directory (path=/srv/headscale_admin)
    [wireguard-ui-trial]      noop: directory /srv/headscale_admin already exists
[wireguard-ui-trial]      >>> sh -c '! (test -e /srv/headscale_admin/.git || test -L /srv/headscale_admin/.git ) || ( stat -c '"'"'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"'"' /srv/headscale_admin/.git 2> /dev/null || stat -f '"'"'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"'"' /srv/headscale_admin/.git )'
    [wireguard-ui-trial]      Loaded fact files.Directory (path=/srv/headscale_admin/.git)
[wireguard-ui-trial]      >>> sh -c '! command -v git >/dev/null || ! test -d /srv/headscale_admin || (cd /srv/headscale_admin && git describe --all)'
    [wireguard-ui-trial]      Loaded fact git.GitBranch (repo=/srv/headscale_admin)
[wireguard-ui-trial]      >>> sh -c 'cd /srv/headscale_admin && git pull'
    [wireguard-ui-trial]      You are not currently on a branch.
    [wireguard-ui-trial]      Please specify which branch you want to merge with.
    [wireguard-ui-trial]      See git-pull(1) for details.
    [wireguard-ui-trial]      
    [wireguard-ui-trial]          git pull <remote> <branch>
    [wireguard-ui-trial]      
    [wireguard-ui-trial]      Error: executed 0 commands

--> Disconnecting from hosts...
--> pyinfra error: No hosts remaining!
```


## Expected behavior
A clear and concise description of what you expected to happen.

Not sure the correct solution, perhaps one of:
- 'pull' is described more completely in documentation
- when a tag is checked out skip the pull (?)

I think the first probably.

Hints from issue comments:
@wowi42: @goetzk fixed

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f27c8e8e..3598d770 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+# v3.3.1
+
+- connectors/ssh: fix extra `keep_alive` key passing through to paramiko `connect` call (@chipot)
+- docs: refine installation guide with updated Python requirements and best practices (@wowi42)
+
 # v3.3
 
 Second release of 2025: loads of adds, fixes and documentation improvements. A huge THANK YOU to all contributors. Slightly changed format for the change list based on commit messages which should speed up releases:
diff --git a/docs/install.md b/docs/install.md
index e43cbbb3..4a36c265 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -5,31 +5,124 @@ orphan: true
 
 # Installation
 
-## Pip
+## Prerequisites
 
-It is recommended to install pyinfra using `pip`:
+### Python Version Requirements
 
-```sh
-pip install pyinfra
-```
+- Python 3.9 or later is required
+- pyinfra 3.2+ supports Python 3.9, 3.10, 3.11, and 3.12
+- You can check your Python version with:
+
+  ```sh
+  python --version
+  ```
+
+### System Requirements
+
+- A Unix-like operating system (Linux, macOS) or Windows
+- Shell access to target systems
+- For Windows users: Administrator privileges for installation
+- For development: A C++ compiler may be required for some Python packages
+
+## Installation Method
+
+### Using pip (Recommended)
+
+#### Create a Virtual Environment (Best Practice)
+
+   ```sh
+   # Create a virtual environment
+   python -m venv env
+
+   # Activate the virtual environment
+   # On macOS/Linux
+   source env/bin/activate
+   # On Windows
+   env\Scripts\activate
+   ```
+
+#### Install pyinfra
+
+   ```sh
+   pip install pyinfra
+   ```
+
+#### Verify Installation
+
+   ```sh
+   pyinfra --version
+   ```
+
+## Platform-Specific Instructions
+
+### Windows Installation
+
+#### Install Python
 
-## Windows
+- Download from [Python's official website](https://www.python.org/downloads/windows/)
+- Run installer as Administrator
+- Check "Add Python to PATH" during installation
 
-Tested on WindowsServer2019 with python 3.7.
+#### Optional: Install C++ Compiler
 
-+ Download Python https://www.python.org/downloads/windows/
-  (ex: python-3.7.6-amd64.exe). Install as Administrator and
-  ensure the **Add Python to PATH** option is selected.)
+- Download [Visual Studio Community Edition](https://visualstudio.microsoft.com/downloads/)
+- Select "Desktop development with C++" workload
+- Required components:
+  - MSVC v142...
+  - Windows 10 SDK...
+  - C++ CMake tools for Windows
+  - C++ ATL for latest...
+  - C++/CLI support for v142...
+  - C++ Modules for v142...
 
-If you need to build any python packages on Windows, perhaps because one of the **pip** packages above fails, you may need a c++ compiler. One possible solution is below.
+#### Install pyinfra
 
-+ Download Visual Studio Community Edition https://visualstudio.microsoft.com/downloads/ and
-  install Visual Studio as Administrator. Select the "Desktop development with C++" option and
-  ensure at least these options are selected:
+   ```sh
+   python -m venv env
+   env\Scripts\activate
+   pip install pyinfra
+   ```
 
-    + "MSVC v142..."
-    + "Windows 10 SDK..."
-    + "C++ cmake tools for windows"
-    + "C++ ATL for latest..."
-    + "C++/cli support for v142..."
-    + "C++ Modules for v142..."
+## Best Practices
+
+### Do's
+
+- ✅ Use virtual environments for isolation
+- ✅ Keep pyinfra and dependencies up-to-date
+- ✅ Use the latest Python version supported
+- ✅ Install as a regular user (not root/sudo)
+
+### Don'ts
+
+- ❌ Avoid system-wide installation
+- ❌ Don't mix pyinfra with other projects in the same virtual environment
+- ❌ Don't use unsupported package managers (poetry, pip-tools)
+
+## Troubleshooting
+
+### Common Issues
+
+#### Permission Errors
+
+- Ensure you're not using sudo for installation
+- Check virtual environment permissions
+- Verify Python installation directory permissions
+
+#### Python Version Issues
+
+- Verify Python version meets requirements
+- Consider using pyenv or similar version manager
+
+#### Virtual Environment Issues
+
+- Ensure virtual environment is properly activated
+- Check PATH environment variable
+- Verify Python interpreter location
+
+## Updating pyinfra
+
+To update to the latest version:
+
+```sh
+pip install --upgrade pyinfra
+```
diff --git a/pyinfra/connectors/sshuserclient/client.py b/pyinfra/connectors/sshuserclient/client.py
index eef4d60d..cfb022af 100644
--- a/pyinfra/connectors/sshuserclient/client.py
+++ b/pyinfra/connectors/sshuserclient/client.py
@@ -158,6 +158,7 @@ class SSHClient(ParamikoClient):
             forward_agent,
             missing_host_key_policy,
             host_keys_file,
+            keep_alive,
         ) = self.parse_config(
             hostname,
             kwargs,
@@ -183,8 +184,6 @@ class SSHClient(ParamikoClient):
         if _pyinfra_ssh_forward_agent is not None:
             forward_agent = _pyinfra_ssh_forward_agent
 
-        keep_alive = config.get("keep_alive")
-
         if keep_alive:
             transport = self.get_transport()
             assert transport is not None, "No transport"
@@ -215,13 +214,14 @@ class SSHClient(ParamikoClient):
         cfg: dict = {"port": 22}
         cfg.update(initial_cfg or {})
 
+        keep_alive = 0
         forward_agent = False
         missing_host_key_policy = get_missing_host_key_policy(strict_host_key_checking)
         host_keys_file = path.expanduser("~/.ssh/known_hosts")  # OpenSSH default
 
         ssh_config = get_ssh_config(ssh_config_file)
         if not ssh_config:
-            return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file
+            return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file, keep_alive
 
         host_config = ssh_config.lookup(hostname)
         forward_agent = host_config.get("forwardagent") == "yes"
@@ -248,7 +248,7 @@ class SSHClient(ParamikoClient):
             cfg["port"] = int(host_config["port"])
 
         if "serveraliveinterval" in host_config:
-            cfg["keep_alive"] = int(host_config["serveraliveinterval"])
+            keep_alive = int(host_config["serveraliveinterval"])
 
         if "proxycommand" in host_config:
             cfg["sock"] = ProxyCommand(host_config["proxycommand"])
@@ -275,7 +275,7 @@ class SSHClient(ParamikoClient):
                 sock = c.gateway(hostname, cfg["port"], target, target_config["port"])
             cfg["sock"] = sock
 
-        return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file
+        return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file, keep_alive
 
     @staticmethod
     def derive_shorthand(ssh_config, host_string):
diff --git a/pyinfra/facts/git.py b/pyinfra/facts/git.py
index 2ad40a77..d9d78bf3 100644
--- a/pyinfra/facts/git.py
+++ b/pyinfra/facts/git.py
@@ -23,6 +23,16 @@ class GitBranch(GitFactBase):
         return re.sub(r"(heads|tags)/", r"", "\n".join(output))
 
 
+class GitTag(GitFactBase):
+    @override
+    def command(self, repo) -> str:
+        return "! test -d {0} || (cd {0} && git tag)".format(repo)
+
+    @override
+    def process(self, output):
+        return output
+
+
 class GitConfig(GitFactBase):
     default = dict
 
diff --git a/pyinfra/operations/git.py b/pyinfra/operations/git.py
index a2dc816c..6e53506f 100644
--- a/pyinfra/operations/git.py
+++ b/pyinfra/operations/git.py
@@ -9,7 +9,7 @@ import re
 from pyinfra import host
 from pyinfra.api import OperationError, operation
 from pyinfra.facts.files import Directory, File
-from pyinfra.facts.git import GitBranch, GitConfig, GitTrackingBranch
+from pyinfra.facts.git import GitBranch, GitConfig, GitTag, GitTrackingBranch
 
 from . import files, ssh
 from .util.files import chown, unix_path_join
@@ -144,10 +144,14 @@ def repo(
             git_commands.append("clone {0} .".format(src))
     # Ensuring existing repo
     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 pull:
+        if branch and branch in host.get_fact(GitTag, repo=dest):
+            git_commands.append("checkout {0}".format(branch))
+            is_tag = True
+        if pull and not is_tag:
             if rebase:
                 git_commands.append("pull --rebase")
             else:


Test patch:
diff --git a/tests/facts/git.GitTag/tag.json b/tests/facts/git.GitTag/tag.json
new file mode 100644
index 00000000..d961f36a
--- /dev/null
+++ b/tests/facts/git.GitTag/tag.json
@@ -0,0 +1,13 @@
+{
+    "arg": "myrepo",
+    "command": "! test -d myrepo || (cd myrepo && git tag)",
+    "requires_command": "git",
+    "output": [
+        "v1.0.0",
+        "v1.0.1"
+    ],
+    "fact": [
+        "v1.0.0",
+        "v1.0.1"
+    ]
+}
diff --git a/tests/operations/git.repo/branch_pull.json b/tests/operations/git.repo/branch_pull.json
index 2cfeaa4f..c8158c55 100644
--- a/tests/operations/git.repo/branch_pull.json
+++ b/tests/operations/git.repo/branch_pull.json
@@ -12,6 +12,10 @@
         },
         "git.GitBranch": {
             "repo=/home/myrepo": "master"
+        },
+        "git.GitTag": {
+            "repo=/home/myrepo": [
+            ]
         }
     },
     "commands": [
diff --git a/tests/test_connectors/test_sshuserclient.py b/tests/test_connectors/test_sshuserclient.py
index 28f1468a..eea2e6e1 100644
--- a/tests/test_connectors/test_sshuserclient.py
+++ b/tests/test_connectors/test_sshuserclient.py
@@ -78,8 +78,10 @@ class TestSSHUserConfigMissing(TestCase):
     def test_load_ssh_config_no_exist(self):
         client = SSHClient()
 
-        _, config, forward_agent, missing_host_key_policy, host_keys_file = client.parse_config(
-            "127.0.0.1",
+        _, config, forward_agent, missing_host_key_policy, host_keys_file, keep_alive = (
+            client.parse_config(
+                "127.0.0.1",
+            )
         )
 
         assert config.get("port") == 22
@@ -126,8 +128,10 @@ class TestSSHUserConfig(TestCase):
     def test_load_ssh_config(self):
         client = SSHClient()
 
-        _, config, forward_agent, missing_host_key_policy, host_keys_file = client.parse_config(
-            "127.0.0.1",
+        _, config, forward_agent, missing_host_key_policy, host_keys_file, keep_alive = (
+            client.parse_config(
+                "127.0.0.1",
+            )
         )
 
         assert config.get("key_filename") == ["/id_rsa", "/id_rsa2"]
@@ -144,6 +148,7 @@ class TestSSHUserConfig(TestCase):
             forward_agent,
             missing_host_key_policy,
             host_keys_file,
+            keep_alive,
         ) = client.parse_config("192.168.1.1")
 
         assert other_config.get("username") == "otheruser"
@@ -198,7 +203,7 @@ class TestSSHUserConfig(TestCase):
         client = SSHClient()
 
         # Load the SSH config with ProxyJump configured
-        _, config, forward_agent, _, _ = client.parse_config(
+        _, config, forward_agent, _, _, _ = client.parse_config(
             "192.168.1.2",
             {"port": 1022},
             ssh_config_file="other_file",