# Root Cause Analysis: test_cli.py Memory Leak and Hang

**Date**: 2026-02-26
**Severity**: Critical (test suite unusable)
**Status**: Root cause identified, fix recommended

---

## Summary

The test suite for `tests/test_cli.py` hangs indefinitely and causes Python to consume 6-8GB of memory because two tests — `test_alias_rename_empty_old_name` and `test_alias_rename_empty_new_name` — trigger an **infinite tight loop** inside `click.prompt()` when invoked via `CliRunner` without terminal input.

---

## Root Cause

### Location

`src/gitflow_analytics/cli.py`, line 6620:

```python
# Interactive mode: display numbered list and prompt for selection
if interactive or not old_name or not new_name:
    ...
    selection = click.prompt(
        "Select developer number to rename (or 0 to cancel)",
        type=click.IntRange(0, len(developer_names)),
    )
```

### The Bug: Logic Error in Input Validation

The `alias_rename` function mixes **input validation** (empty string rejection) with **interactive mode triggering** in a single conditional. The check at line 6620:

```python
if interactive or not old_name or not new_name:
```

is intended to enter interactive mode when names are missing, but it fires on **empty strings** as well as `None`. When the tests pass `--old-name ""` or `--new-name ""`, these are falsy in Python (`not "" == True`), so the condition evaluates to `True` and the code enters the interactive branch — calling `click.prompt()`.

The actual empty-string validation (which should immediately exit with an error) is placed **after** the interactive block at line 6660:

```python
if not old_name or not old_name.strip():
    click.echo("❌ Error: --old-name cannot be empty", err=True)
    sys.exit(1)
```

This validation is **unreachable** in the test scenario because `click.prompt()` blocks first.

### Why click.prompt() Causes an Infinite Loop in Tests

When `CliRunner.invoke()` is used without providing `input=`, the runner's stdin is an empty `BytesIO` buffer. When `click.prompt()` reads from this empty buffer:

1. It reads EOF (empty string) as the user's input
2. With `type=click.IntRange(0, N)`, the empty string fails validation
3. `click.prompt()` re-displays the prompt and retries — **indefinitely**
4. Each retry appends the prompt text to CliRunner's in-memory output buffer

**Measured output buffer growth**: 40MB in 3 seconds (verified with `tracemalloc`).

Over minutes, this reaches 6-8GB, completely consuming available RAM.

---

## Affected Tests

### Primary Offenders (infinite hang)

1. **`TestAliasRename::test_alias_rename_empty_old_name`** (line 202)
   - Passes `--old-name ""` (empty string)
   - `not ""` = `True` → enters interactive block → `click.prompt()` infinite loop

2. **`TestAliasRename::test_alias_rename_empty_new_name`** (line 224)
   - Passes `--new-name ""` (empty string), `--old-name "Old Name"` (truthy)
   - `not ""` = `True` → enters interactive block → `click.prompt()` on selection prompt, infinite loop

### Tests That Run Fine (verified)

All other tests complete normally:
- `TestCLI` class: all 9 tests pass in ~1s
- `TestVersionDisplay`: passes
- `TestCreateAliasInteractive`: both tests pass
- `TestAliasRename::test_alias_rename_help`: passes
- `TestAliasRename::test_alias_rename_missing_config`: passes
- `TestGitHubAuthConditional`: all 3 tests pass (including `--validate-only` which calls `analyze()`)

---

## Secondary Finding: Large Import Spike on First analyze() Call

The first call to `analyze()` (or `analyze_subcommand()`) causes a **one-time memory jump** from ~25MB to ~146MB due to 14 lazy imports inside the function body:

```python
from .core.analyzer import GitAnalyzer
from .core.cache import GitAnalysisCache
from .core.git_auth import preflight_git_authentication
from .core.identity import DeveloperIdentityResolver
from .core.progress import get_progress_service
from .integrations.orchestrator import IntegrationOrchestrator
from .metrics.dora import DORAMetricsCalculator
from .reports.analytics_writer import AnalyticsReportGenerator
from .reports.csv_writer import CSVReportGenerator
from .reports.json_exporter import ComprehensiveJSONExporter
from .reports.narrative_writer import NarrativeReportGenerator
from .reports.weekly_trends_writer import WeeklyTrendsWriter
```

**This is not a memory leak** — the 146MB stays stable across all subsequent `analyze()` calls. It is the fixed cost of loading the analysis stack. This is intentional (lazy loading for fast `--help` performance) and is not causing the 6-8GB runaway.

---

## Memory Leak Mechanism (Proven)

```
test invoked → CliRunner.invoke(alias_rename, ['--old-name', ''])
    → alias_rename() enters interactive branch
    → click.prompt("Select developer number", type=click.IntRange(0, 1))
    → CliRunner stdin = empty BytesIO
    → click reads '' → IntRange validation fails → retry
    → INFINITE LOOP: each retry appends "Select developer number: \n" to CliRunner output buffer
    → buffer grows without bound at ~13MB/sec
    → test never terminates
    → pytest also never completes (300s timeout = 3.9GB buffer)
```

---

## Fix Recommendation

### Option 1 (Correct, Minimal Change): Validate Before Entering Interactive Mode

Move the empty-string validation check to **before** the interactive mode branch:

```python
# In alias_rename(), before the interactive block (around line 6618):

# Validate empty inputs BEFORE entering interactive mode
if not interactive:  # Only validate non-interactive direct inputs
    if old_name is not None and not old_name.strip():
        click.echo("❌ Error: --old-name cannot be empty", err=True)
        sys.exit(1)
    if new_name is not None and not new_name.strip():
        click.echo("❌ Error: --new-name cannot be empty", err=True)
        sys.exit(1)

# Interactive mode: only enter if explicitly requested or names not given at all
if interactive or old_name is None or new_name is None:
    ...
```

### Option 2 (Most Correct): Separate Empty-String from None

Click passes empty string `""` when `--old-name ""` is used. Treat `None` (not provided) and `""` (explicitly empty) differently:

```python
# Check for explicitly empty arguments first (before interactive logic)
if old_name is not None and not old_name.strip():
    click.echo("❌ Error: --old-name cannot be empty", err=True)
    sys.exit(1)

if new_name is not None and not new_name.strip():
    click.echo("❌ Error: --new-name cannot be empty", err=True)
    sys.exit(1)

# Now check for interactive mode (only when values are missing, not empty)
if interactive or old_name is None or new_name is None:
    # ... interactive prompt logic
```

### Option 3 (Test Workaround, Not Recommended): Provide input to CliRunner

The tests could provide a cancellation input:

```python
result = runner.invoke(
    alias_rename,
    ["--config", "test-config.yaml", "--old-name", "", "--new-name", "New Name"],
    input="0\n",  # Cancel selection
)
```

But this is wrong — the tests are validating correct error handling for empty names. The CLI logic should fail fast before prompting.

---

## Verification Evidence

```
# Empirically measured (2026-02-26):
[help]           time=0.00s  current=25.1MB   exit=0   # No analyze() call
[analyze_help]   time=0.00s  current=25.1MB   exit=0   # No analyze() call
[missing_config] time=4.38s  current=146.2MB  exit=1   # First analyze() call, lazy import spike
# After first analyze() call, memory stays at ~146MB regardless of more calls
# Repeated analyze() calls add < 0.2MB each (stable, no leak)

# click.prompt() infinite loop test:
# 40MB of output generated in < 3 seconds
# "Select developer number: " repeating millions of times
```

---

## Files Involved

| File | Issue |
|------|-------|
| `src/gitflow_analytics/cli.py` line 6620 | Bug: empty-string check triggers interactive mode |
| `tests/test_cli.py` line 216-222 | Test: `test_alias_rename_empty_old_name` (hangs) |
| `tests/test_cli.py` line 233-244 | Test: `test_alias_rename_empty_new_name` (hangs) |

---

## Conclusion

The memory leak is caused by a **logic error in `alias_rename`**: the empty string validation guard comes after an interactive prompt block that is triggered by empty inputs. The `CliRunner`'s empty stdin causes `click.prompt()` to retry infinitely, generating an unbounded output buffer.

The fix is simple: validate for explicitly empty strings before deciding whether to enter interactive mode.
