From 02f0863a24dc3e6c81a28bf1f37834bd87ef295a Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:28:06 +0300 Subject: [PATCH] fix(memory): persist git insteadOf helper for GITHUB_TOKEN in setup-memory.sh (#53) * fix(memory): add git insteadOf helper for GITHUB_TOKEN in setup-memory.sh * test(memory): add tests for git insteadOf helper * docs(handoff): add handoff + ADR-022 for git insteadOf helper * docs(handoff): set PR number 53 in handoff + ADR-022 * style: ruff format test_setup_memory.py --------- Co-authored-by: opencode-agent --- .opencode/scripts/setup-memory.sh | 5 ++ docs/decisions/022-pr-53-git-insteadof.md | 43 +++++++++ docs/handoff/pr-53-git-insteadof.md | 31 +++++++ tests/test_setup_memory.py | 102 ++++++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 docs/decisions/022-pr-53-git-insteadof.md create mode 100644 docs/handoff/pr-53-git-insteadof.md diff --git a/.opencode/scripts/setup-memory.sh b/.opencode/scripts/setup-memory.sh index e724e1d..71176d7 100755 --- a/.opencode/scripts/setup-memory.sh +++ b/.opencode/scripts/setup-memory.sh @@ -26,6 +26,11 @@ fi # 2. .git exists? → clone (no) | pull --ff-only (yes) if [ ! -d "$MEMORY_DIR/.git" ]; then echo " [2/6] cloning remote: $REMOTE" + # Ensure GITHUB_TOKEN is used for HTTPS git operations (non-interactive). + # Idempotent: git config --global overwrites (not duplicates) the value. + if [ -n "${GITHUB_TOKEN:-}" ]; then + git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf https://github.com/ + fi git clone --origin origin "$REMOTE" "$MEMORY_DIR" || { echo "ERROR: clone failed" >&2; exit 1; } git -C "$MEMORY_DIR" checkout "$BRANCH" 2>/dev/null || true else diff --git a/docs/decisions/022-pr-53-git-insteadof.md b/docs/decisions/022-pr-53-git-insteadof.md new file mode 100644 index 0000000..d62a1b3 --- /dev/null +++ b/docs/decisions/022-pr-53-git-insteadof.md @@ -0,0 +1,43 @@ +# ADR-022 (PR 53): Persist git insteadOf helper for GITHUB_TOKEN in setup-memory.sh + +## Статус +Accepted (2026-07-24) + +## Контекст + +При миграции opencode-config на linux-1 `setup-memory.sh` падал с `could not read Username for 'https://github.com'` при клонировании memory repo (`OPENCODE_MEMORY_REMOTE` = HTTPS GitHub URL). Git не подставляет `GITHUB_TOKEN` автоматически для HTTPS-remote — в non-interactive контексте (Docker container, нет tty) prompt блокирует clone, скрипт висит или падает. + +Временный fix применялся вручную в контейнере: +```bash +git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf https://github.com/ +``` + +Но `~/.gitconfig` живёт в overlay container filesystem (Docker) и **теряется при `docker compose restart`**. После рестарта memory-clone снова падал — manual fix не persistent. + +Нужен детерминированный механизм, который гарантированно применяет insteadOf при каждом запуске `setup-memory.sh` (вызывается через memory-setup tool при старте opencode). + +## Решение + +Встроить guarded `git config --global url.insteadOf` блок в `.opencode/scripts/setup-memory.sh`, перед `git clone`: + +```bash +if [ -n "${GITHUB_TOKEN:-}" ]; then + git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf https://github.com/ +fi +``` + +- **Guarded check** — если `GITHUB_TOKEN` пуст/unset, блок пропускается без ошибки (локальная dev-среда с file-based mock remote не падает). +- **Inside clone-branch** — helper стоит внутри `if [ ! -d "$MEMORY_DIR/.git" ]`, срабатывает только при первом клонировании. На pull-path не нужен: `origin` URL уже сохранён в `.git/config` клона. +- **Idempotent** — `git config --global url..insteadOf` перезаписывает значение при повторном вызове, не дублирует entry. +- **4 теста** в `tests/test_setup_memory.py`: grep check (insteadOf presence + ordering before clone), grep check (GITHUB_TOKEN guarded), functional idempotency (2x run → 1 insteadOf entry in isolated HOME), functional skip (no token → no insteadOf). + +### Альтернативы + +- **Dockerfile ENTRYPOINT / `.bashrc` hack** (`RUN echo 'git config ...' >> /root/.bashrc`) — отклонено: `.bashrc` читается только interactive shell'ом, non-interactive container (opencode spawn) его не читает. ENTRYPOINT hack смешивает git-config с container lifecycle, менее детерминированно чем скрипт, вызываемый через tool. + +- **Hard-fail если `GITHUB_TOKEN` unset** — отклонено: ломает локальную dev-среду (mock file-remote, offline tests). Guarded check (`if [ -n ... ]`) позволяет скрипту работать с любым remote — HTTPS GitHub (нужен token) или file-based mock (token не нужен). + +- **Применять insteadOf на каждом запуске (вне clone-ветки)** — отклонено: на pull-path `origin` URL уже сохранён в `.git/config`, insteadOf не нужен. Дублирование вызова `git config --global` на каждом запуске — лишняя запись в gitconfig без пользы (хоть и idempotent). Clone-ветка — единственное место, где remote URL впервые передаётся git. + +## Альтернативы +См. блок «Альтернативы» выше. Кратко: отклонены — Dockerfile/`.bashrc` hack (non-interactive), hard-fail on missing token (ломает dev), insteadOf на каждом запуске (лишнее). Выбран guarded insteadOf внутри clone-ветки (детерминированно, idempotent, dev-safe). \ No newline at end of file diff --git a/docs/handoff/pr-53-git-insteadof.md b/docs/handoff/pr-53-git-insteadof.md new file mode 100644 index 0000000..98893a0 --- /dev/null +++ b/docs/handoff/pr-53-git-insteadof.md @@ -0,0 +1,31 @@ +--- +pr: 53 +title: persist git insteadOf helper for GITHUB_TOKEN in setup-memory.sh +--- + +# PR 53: persist git insteadOf helper for GITHUB_TOKEN in setup-memory.sh + +## Что сделано +- `.opencode/scripts/setup-memory.sh:29-33` — перед `git clone` добавлен guarded блок `git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf https://github.com/`. Срабатывает только если `GITHUB_TOKEN` непустой (`if [ -n "${GITHUB_TOKEN:-}" ]`), иначе пропускается без ошибки. Расположен ВНУТРИ ветки clone (`if [ ! -d "$MEMORY_DIR/.git" ]`), т.к. insteadOf нужен только при первом клонировании remote — pull на существующем репо уже авторизован через `origin` URL. +- `tests/test_setup_memory.py` — 4 новых теста: + - `test_script_contains_git_insteadof` — grep check: скрипт содержит `git config --global url.` + `.insteadOf https://github.com/`, и блок стоит ДО `git clone --origin` (позиционная проверка `insteadof_pos < clone_pos`). + - `test_script_contains_github_token_check` — grep check: скрипт содержит `GITHUB_TOKEN` и это guarded check (`-n "${GITHUB_TOKEN:-}"`), а не hard-fail. + - `test_insteadof_idempotent` — функциональный тест: запускает скрипт 2x с fake `GITHUB_TOKEN` против mock remote (isolated `HOME`), проверяет что `~/.gitconfig` содержит ровно 1 `insteadOf` entry (не дублируется). `git config --global` перезаписывает значение, idempotent by git semantics. + - `test_no_github_token_skips_insteadof` — когда `GITHUB_TOKEN` unset, `~/.gitconfig` либо не создаётся, либо не содержит `insteadOf` (skip без ошибки). +- ADR-022 + этот handoff. + +## Почему +При миграции opencode-config на linux-1 `setup-memory.sh` падал с `could not read Username for 'https://github.com'` при клонировании memory repo — git не подставляет `GITHUB_TOKEN` автоматически для HTTPS, и в non-interactive контексте (container, нет tty) prompts блокируют clone. Временный fix (`git config --global url.insteadOf`) применялся вручную, но `~/.gitconfig` живёт в overlay container filesystem и теряется при `docker compose restart` — после рестарта memory-clone снова падал. + +Решение: встроить insteadOf helper в сам `setup-memory.sh` (детерминированный flow, вызывается через memory-setup tool при старте opencode). Скрипт idempotent — `git config --global` перезаписывает значение, не дублирует. Guarded check на `GITHUB_TOKEN` — если token отсутствует (локальная dev-среда), скрипт не падает, клон идёт как обычно (для mock/file remotes). + +Альтернатива (Dockerfile ENTRYPOINT / `.bashrc` hack) отклонена — `setup-memory.sh` вызывается детерминированно через tool, а `.bashrc` требует interactive shell (non-interactive container его не читает). + +## Pending +— (нет) + +## Watch out +- **insteadOf внутри clone-ветки** — helper стоит внутри `if [ ! -d "$MEMORY_DIR/.git" ]`, т.е. срабатывает только при первом клонировании. На последующих запусках (pull path) вместо него не выполняется — это намеренно: `origin` URL уже сохранён в `.git/config` клона, pull идёт через него. Если remote URL не содержит token (HTTPS без auth), pull упадёт — но это уже проблема конфигурации remote, а не скрипта. Для fresh clone вместоOf переписывает `https://github.com/` на `https://x-access-token:TOKEN@github.com/` в момент clone. +- **Isolated HOME в тестах** — `test_insteadof_idempotent` и `test_no_github_token_skips_insteadof` используют `HOME=$tmp_path/home`, чтобы `git config --global` не писал в реальный `~/.gitconfig` CI-раннера. Без этого тест загрязнил бы глобальный gitconfig. +- **ADR number = 022** (sequential, следующий после 021), НЕ PR number. +- Существующие 358 тестов не сломаны — полный suite: 362 passed (358 + 4 новых). \ No newline at end of file diff --git a/tests/test_setup_memory.py b/tests/test_setup_memory.py index 67ff050..948451d 100644 --- a/tests/test_setup_memory.py +++ b/tests/test_setup_memory.py @@ -215,5 +215,107 @@ def test_no_remote_env(tmp_path: Path) -> None: assert "ERROR" in combined or "not set" in combined +# ── git insteadOf helper (GITHUB_TOKEN) ────────────────────────────────────── + + +def test_script_contains_git_insteadof() -> None: + """setup-memory.sh contains `git config --global url.insteadOf` before clone.""" + src = SCRIPT.read_text() + assert "git config --global url." in src, "missing git config --global url. call" + assert ".insteadOf https://github.com/" in src, "missing insteadOf directive" + # The insteadOf block must appear BEFORE the git clone line. + insteadof_pos = src.index(".insteadOf") + clone_pos = src.index("git clone --origin") + assert insteadof_pos < clone_pos, "insteadOf helper must precede git clone" + + +def test_script_contains_github_token_check() -> None: + """setup-memory.sh checks GITHUB_TOKEN (guarded, not hard-fail if empty).""" + src = SCRIPT.read_text() + assert "GITHUB_TOKEN" in src, "missing GITHUB_TOKEN reference" + # Must be a conditional check (if [ -n ... ]), not an unconditional hard fail. + assert '-n "${GITHUB_TOKEN:-}"' in src or '-n "$GITHUB_TOKEN"' in src, ( + "GITHUB_TOKEN must be a guarded check (if [ -n ... ]), not a hard requirement" + ) + + +def test_insteadof_idempotent(tmp_path: Path) -> None: + """Running the script N times does not duplicate the insteadOf entry. + + `git config --global url..insteadOf` overwrites the value on each + call (idempotent by git semantics). We verify by invoking setup-memory.sh + twice with a fake GITHUB_TOKEN against a mock remote and checking that + `git config --global --get-all` returns exactly one match after each run. + """ + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + + # Use an isolated HOME so --global writes to a tmp gitconfig (no pollution). + home = tmp_path / "home" + home.mkdir() + + env = { + **os.environ, + "OPENCODE_MEMORY_DIR": str(mem), + "OPENCODE_MEMORY_REMOTE": str(remote), + "GITHUB_TOKEN": "fake-token-for-test", + "HOME": str(home), + "GIT_TERMINAL_PROMPT": "0", + } + + # First run: clone path triggers the insteadOf helper. + r1 = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env) + assert r1.returncode == 0, f"run1 failed: stdout={r1.stdout}\nstderr={r1.stderr}" + + # Read the global gitconfig written by the script. + gitconfig = home / ".gitconfig" + assert gitconfig.exists(), f"gitconfig not created at {gitconfig}" + content1 = gitconfig.read_text() + assert "x-access-token:fake-token-for-test@github.com" in content1 + assert "insteadOf" in content1 + + # Count insteadOf occurrences — should be exactly 1 (no duplication). + insteadof_count_1 = content1.count("insteadOf") + assert insteadof_count_1 == 1, f"expected 1 insteadOf after run1, got {insteadof_count_1}" + + # Second run: existing repo → pull path (clone branch skipped). The helper + # is inside the clone branch, so it won't re-fire — but we assert the + # config is NOT duplicated regardless (single insteadOf entry preserved). + r2 = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env) + assert r2.returncode == 0, f"run2 failed: stdout={r2.stdout}\nstderr={r2.stderr}" + content2 = gitconfig.read_text() + insteadof_count_2 = content2.count("insteadOf") + assert insteadof_count_2 == 1, ( + f"expected 1 insteadOf after run2, got {insteadof_count_2} (duplicate?)" + ) + + +def test_no_github_token_skips_insteadof(tmp_path: Path) -> None: + """When GITHUB_TOKEN is unset, the insteadOf helper is skipped (no error).""" + remote = tmp_path / "remote.git" + _seed_remote(remote) + mem = tmp_path / "mem" + + home = tmp_path / "home" + home.mkdir() + + env = { + **os.environ, + "OPENCODE_MEMORY_DIR": str(mem), + "OPENCODE_MEMORY_REMOTE": str(remote), + "HOME": str(home), + "GIT_TERMINAL_PROMPT": "0", + } + env.pop("GITHUB_TOKEN", None) + + r = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + gitconfig = home / ".gitconfig" + if gitconfig.exists(): + content = gitconfig.read_text() + assert "insteadOf" not in content, "insteadOf set without GITHUB_TOKEN" + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"]))