Run ``git `` in ``cwd``. Returns ``(returncode, stderr)``. stdout is discarded — worktree commands don't return data we need. Every failure mode — missing git binary, cwd unreadable, subprocess spawn failure, communicate timeout — is converted to a non-zero return code so call
(*args: str, cwd: str)
| 72 | |
| 73 | |
| 74 | async def _run_git(*args: str, cwd: str) -> tuple[int, str]: |
| 75 | """Run ``git <args>`` in ``cwd``. Returns ``(returncode, stderr)``. |
| 76 | |
| 77 | stdout is discarded — worktree commands don't return data we need. |
| 78 | Every failure mode — missing git binary, cwd unreadable, subprocess |
| 79 | spawn failure, communicate timeout — is converted to a non-zero |
| 80 | return code so callers can fall back on a simple rc check. The |
| 81 | one thing this CAN'T do is hang: ``_GIT_TIMEOUT_S`` caps the wait |
| 82 | and we ``kill()`` the subprocess on timeout. |
| 83 | """ |
| 84 | try: |
| 85 | proc = await asyncio.create_subprocess_exec( |
| 86 | 'git', *args, |
| 87 | cwd=cwd, |
| 88 | stdout=asyncio.subprocess.DEVNULL, |
| 89 | stderr=asyncio.subprocess.PIPE, |
| 90 | ) |
| 91 | except (OSError, ValueError) as err: |
| 92 | # FileNotFoundError (git not on PATH), PermissionError (cwd |
| 93 | # not +x), NotADirectoryError, etc. — all OSError subclasses. |
| 94 | # ValueError can come from create_subprocess_exec when args |
| 95 | # contain NULs. |
| 96 | return -1, str(err) |
| 97 | # The try/finally guarantees we kill the subprocess on every exit |
| 98 | # path: timeout, ``CancelledError`` from a parent task, or any |
| 99 | # other exception. Without it, a cancelled ``_run_git`` leaves the |
| 100 | # ``git`` process running after the daemon has shut down — see |
| 101 | # the cancellation-cleanup discussion in the Phase 12a review. |
| 102 | try: |
| 103 | try: |
| 104 | _, stderr_bytes = await asyncio.wait_for( |
| 105 | proc.communicate(), timeout=_GIT_TIMEOUT_S, |
| 106 | ) |
| 107 | except asyncio.TimeoutError: |
| 108 | return -1, f'git timed out after {_GIT_TIMEOUT_S:.2f}s' |
| 109 | finally: |
| 110 | if proc.returncode is None: |
| 111 | try: |
| 112 | proc.kill() |
| 113 | except ProcessLookupError: |
| 114 | pass |
| 115 | # Drain so the transport releases its FDs. Bounded so a |
| 116 | # truly wedged process can't hang the finally block. |
| 117 | try: |
| 118 | await asyncio.wait_for(proc.wait(), timeout=1.0) |
| 119 | except (asyncio.TimeoutError, asyncio.CancelledError): |
| 120 | pass |
| 121 | rc = proc.returncode if proc.returncode is not None else -1 |
| 122 | return rc, stderr_bytes.decode('utf-8', 'replace') |
| 123 | |
| 124 | |
| 125 | async def _is_git_repo(path: str) -> bool: |
no test coverage detected