# AI Agent Instructions: `agentic_devtools/cli/git/`

> Parent Instruction Chain
>
> 1. [agentic-devtools package instructions](../../../.github/copilot-instructions.md)
>
> [`copilot-instructions.md` Precedence Model](../../../docs/copilot-instructions-precedence-model.md)

## Scope

These instructions apply to Copilot and AI agents when writing or reviewing Python code
in the `agentic_devtools/cli/git/` package.

---

## 1. Token/Credential Redaction Must Cover Exception Messages

*Based on audit of PR #2657*

When a git command embeds a credential in the remote URL (e.g., `https://x-access-token:{token}@github.com/...`),
redacting the token from captured **stderr/stdout** is not sufficient. `subprocess.CalledProcessError`
captures the raw `cmd` list and includes it in `__str__`, so any caught-and-logged exception will expose
the credential even when output streams are clean. The inverse is also unsafe: sanitizing only `cmd`
still leaks the credential if `git` echoes the tokenized remote URL into stderr or stdout.

**Required pattern:**

```python
from urllib.parse import urlsplit, urlunsplit


def _redacted_remote_url(raw_url: str | None) -> tuple[str | None, str | None]:
    """Return (redacted_url, bare_password) so callers can sanitize both forms."""
    if not raw_url:
        return raw_url, None
    parts = urlsplit(raw_url)
    if "@" not in parts.netloc:
        return raw_url, None
    userinfo, host = parts.netloc.rsplit("@", 1)
    username, sep, secret = userinfo.partition(":")
    if not sep or not secret:
        return raw_url, None
    safe_userinfo = f"{username}:<redacted>"
    safe_url = urlunsplit((parts.scheme, f"{safe_userinfo}@{host}", parts.path, parts.query, parts.fragment))
    return safe_url, secret


redacted_remote_url, _token_password = _redacted_remote_url(token_remote_url)


# Coerce every arg to str: subprocess cmd lists may contain Path objects, which
# do not have string .replace() semantics — Path.replace() is a filesystem rename API
# with a different signature, so treating a Path like a string here will typically raise
# rather than redact. Always str(arg) before substituting.
# Apply both the full-URL replacement and the bare-password replacement so a normalized
# or rewritten URL (e.g., with stripped whitespace) cannot leave the token in safe_cmd.
def _sanitize_arg(arg: object) -> str:
    s = str(arg)
    if token_remote_url and redacted_remote_url:
        s = s.replace(token_remote_url, redacted_remote_url)
    if _token_password:
        s = s.replace(_token_password, "<redacted>")
    return s


safe_cmd = [_sanitize_arg(arg) for arg in cmd]


def _redact(value: str | None) -> str | None:
    if value is None:
        return value
    # Replace full tokenized URL first, then any bare password that git may echo
    # independently (e.g., only the password portion in a credential-helper error).
    result = value
    if token_remote_url and redacted_remote_url:
        result = result.replace(token_remote_url, redacted_remote_url)
    if _token_password:
        result = result.replace(_token_password, "<redacted>")
    return result


try:
    subprocess.run(
        cmd,
        check=True,
        capture_output=True,
        text=True,
        shell=False,
    )
except subprocess.CalledProcessError as e:
    # Raise with fully sanitized fields so token never appears in exception strings or logs
    raise subprocess.CalledProcessError(
        returncode=e.returncode,
        cmd=safe_cmd,
        output=_redact(e.output),
        stderr=_redact(e.stderr),
    ) from e
```

Apply this pattern anywhere `token_remote_url` or a bearer token appears in a subprocess `cmd`
list or may be echoed back in captured subprocess output. Two details are required for complete
coverage:

- **Coerce `cmd` elements to `str`** — subprocess arg lists may contain `pathlib.Path` objects,
  which do not have string `.replace()` semantics. `Path.replace()` is a filesystem rename API
  with a different signature, so treating a `Path` like a string here will typically raise rather
  than redact. Always `str(arg)` before substituting.
- **Redact the bare password separately** — `git` and credential helpers sometimes echo only the
  password portion of a remote URL (not the full tokenized `https://user:<token>@host/...` form).
  Extract the password from the parsed URL and apply a second `.replace()` pass in `_redact()` so
  this form is also sanitized.

---

## 2. Empty and Whitespace Strings Are Not Valid Remote URLs

*Based on audit of PR #2657*

The `push_dest` selection logic must reject empty and whitespace-only strings, not just `None`.
Using `if token_remote_url is not None` allows an empty string `""` to be selected, which
produces a confusing `git push "" ...` failure that is hard to diagnose.

**Correct guard:**

```python
# ✅ Rejects None, "", and whitespace
token_push_dest = token_remote_url.strip() if token_remote_url and token_remote_url.strip() else None
push_dest = token_push_dest or origin_remote
```

This also aligns with the downstream redaction logic, which treats empty strings as absent and
ensures the selected destination is already normalized before `git push` receives it.
