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 <agent@slaid098.dev>
This commit is contained in:
parent
655a98d077
commit
02f0863a24
4 changed files with 181 additions and 0 deletions
|
|
@ -26,6 +26,11 @@ fi
|
||||||
# 2. .git exists? → clone (no) | pull --ff-only (yes)
|
# 2. .git exists? → clone (no) | pull --ff-only (yes)
|
||||||
if [ ! -d "$MEMORY_DIR/.git" ]; then
|
if [ ! -d "$MEMORY_DIR/.git" ]; then
|
||||||
echo " [2/6] cloning remote: $REMOTE"
|
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 clone --origin origin "$REMOTE" "$MEMORY_DIR" || { echo "ERROR: clone failed" >&2; exit 1; }
|
||||||
git -C "$MEMORY_DIR" checkout "$BRANCH" 2>/dev/null || true
|
git -C "$MEMORY_DIR" checkout "$BRANCH" 2>/dev/null || true
|
||||||
else
|
else
|
||||||
|
|
|
||||||
43
docs/decisions/022-pr-53-git-insteadof.md
Normal file
43
docs/decisions/022-pr-53-git-insteadof.md
Normal file
|
|
@ -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.<prefix>.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).
|
||||||
31
docs/handoff/pr-53-git-insteadof.md
Normal file
31
docs/handoff/pr-53-git-insteadof.md
Normal file
|
|
@ -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 новых).
|
||||||
|
|
@ -215,5 +215,107 @@ def test_no_remote_env(tmp_path: Path) -> None:
|
||||||
assert "ERROR" in combined or "not set" in combined
|
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.<prefix>.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__":
|
if __name__ == "__main__":
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
sys.exit(pytest.main([__file__, "-v"]))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue