chore(memory): remove plugin + setup-memory.sh + Rust artifacts (#103)
* chore(memory): remove plugin block from opencode.json * chore(memory): remove setup-memory.sh and memory-setup.ts * chore(memory): remove opencode-memory from Dockerfile * docs(memory): update AGENTS.md and SKILL.md for new tools * test(memory): drop tests for removed setup-memory.sh and memory-setup.ts * docs(handoff): add handoff and ADR-045 for plugin/rust removal * docs(handoff): set PR number * docs(project-map): update after plugin/rust removal --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
9d1373a51b
commit
9d654a2201
14 changed files with 198 additions and 774 deletions
|
|
@ -1,13 +1,5 @@
|
|||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
[
|
||||
"@mathew-cf/opencode-memory",
|
||||
{
|
||||
"memoryDir": "{env:OPENCODE_MEMORY_DIR}"
|
||||
}
|
||||
]
|
||||
],
|
||||
"skills": {
|
||||
"paths": [
|
||||
".opencode/skills"
|
||||
|
|
|
|||
|
|
@ -1,127 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MEMORY_DIR="${OPENCODE_MEMORY_DIR:-/root/.local/share/opencode/opencode-memory}"
|
||||
HOOK="$MEMORY_DIR/.git/hooks/post-commit"
|
||||
BRANCH="master"
|
||||
WRAPPER_PATH="${MEMORY_WRAPPER_PATH:-/usr/local/lib/node_modules/@mathew-cf/opencode-memory/node_modules/@mathew-cf/rag-cli/bin/rag.js}"
|
||||
|
||||
# Pick a Python that can import src.memory: prefer workspace .venv, then CWD
|
||||
# .venv, then system python3. Override via MEMORY_WRAPPER_PYTHON for tests.
|
||||
if [ -n "${OPENCODE_WORKSPACE:-}" ] && [ -x "${OPENCODE_WORKSPACE}/.venv/bin/python" ]; then
|
||||
MEMORY_PYTHON="${MEMORY_WRAPPER_PYTHON:-${OPENCODE_WORKSPACE}/.venv/bin/python}"
|
||||
elif [ -x ".venv/bin/python" ]; then
|
||||
MEMORY_PYTHON="${MEMORY_WRAPPER_PYTHON:-.venv/bin/python}"
|
||||
else
|
||||
MEMORY_PYTHON="${MEMORY_WRAPPER_PYTHON:-python3}"
|
||||
fi
|
||||
EXPECTED_HOOK="#!/bin/bash
|
||||
git push origin ${BRANCH} 2>/dev/null || true"
|
||||
|
||||
if [ -z "${OPENCODE_MEMORY_REMOTE:-}" ]; then
|
||||
echo "ERROR: OPENCODE_MEMORY_REMOTE not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
REMOTE="${OPENCODE_MEMORY_REMOTE}"
|
||||
|
||||
echo "memory setup: $MEMORY_DIR"
|
||||
|
||||
# 1. MEMORY_DIR exists? → mkdir -p
|
||||
if [ ! -d "$MEMORY_DIR" ]; then
|
||||
mkdir -p "$MEMORY_DIR"
|
||||
echo " [1/6] created directory: $MEMORY_DIR"
|
||||
else
|
||||
echo " [1/6] directory exists"
|
||||
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
|
||||
echo " [2/6] pulling latest (ff-only)"
|
||||
git -C "$MEMORY_DIR" pull --ff-only origin "$BRANCH" 2>/dev/null || \
|
||||
echo " pull skipped (no upstream or offline)"
|
||||
fi
|
||||
|
||||
# 3. remote origin correct? → set-url (no) | noop (yes)
|
||||
CURRENT_REMOTE="$(git -C "$MEMORY_DIR" remote get-url origin 2>/dev/null || echo "")"
|
||||
if [ "$CURRENT_REMOTE" != "$REMOTE" ]; then
|
||||
echo " [3/6] fixing remote: $CURRENT_REMOTE → $REMOTE"
|
||||
git -C "$MEMORY_DIR" remote set-url origin "$REMOTE"
|
||||
else
|
||||
echo " [3/6] remote correct"
|
||||
fi
|
||||
|
||||
# 4. post-commit hook exists + content correct? → create/fix (no) | noop (yes)
|
||||
NEEDS_HOOK=0
|
||||
if [ ! -f "$HOOK" ]; then
|
||||
NEEDS_HOOK=1
|
||||
elif [ "$(cat "$HOOK")" != "$EXPECTED_HOOK" ]; then
|
||||
NEEDS_HOOK=1
|
||||
fi
|
||||
if [ "$NEEDS_HOOK" -eq 1 ]; then
|
||||
echo " [4/6] installing post-commit hook (auto-push)"
|
||||
mkdir -p "$(dirname "$HOOK")"
|
||||
printf '%s\n' "$EXPECTED_HOOK" > "$HOOK"
|
||||
chmod +x "$HOOK"
|
||||
else
|
||||
echo " [4/6] hook correct"
|
||||
fi
|
||||
|
||||
# 5. .rag/index.json exists? → memory index (no) | noop (yes)
|
||||
if "$MEMORY_PYTHON" -c "import src.memory" >/dev/null 2>&1; then
|
||||
if [ ! -f "$MEMORY_DIR/.rag/index.json" ]; then
|
||||
echo " [5/7] building RAG index (memory CLI)"
|
||||
( "$MEMORY_PYTHON" -m src.memory index "$MEMORY_DIR" -o "$MEMORY_DIR/.rag" ) 2>&1 \
|
||||
|| echo " memory index failed — continuing"
|
||||
else
|
||||
echo " [5/7] RAG index exists"
|
||||
fi
|
||||
else
|
||||
echo " [5/7] memory CLI not available — skipping index"
|
||||
fi
|
||||
|
||||
# 5b. JS wrapper for opencode-memory plugin — delegates rag CLI calls to
|
||||
# Python memory CLI. Idempotent (content check via cmp), backs up
|
||||
# original once.
|
||||
WRAPPER_DIR="$(dirname "$WRAPPER_PATH")"
|
||||
mkdir -p "$WRAPPER_DIR"
|
||||
WRAPPER_CONTENT='#!/usr/bin/env node
|
||||
// Generated by setup-memory.sh — delegates rag CLI calls to Python memory CLI
|
||||
const { spawnSync } = require("child_process");
|
||||
const py = process.env.MEMORY_WRAPPER_PYTHON || "'"$MEMORY_PYTHON"'";
|
||||
const r = spawnSync(py, ["-m", "src.memory", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
cwd: process.env.OPENCODE_WORKSPACE || process.cwd()
|
||||
});
|
||||
process.exit(r.status || 0);
|
||||
'
|
||||
|
||||
if [ -f "$WRAPPER_PATH" ] && cmp -s "$WRAPPER_PATH" /dev/stdin <<<"$WRAPPER_CONTENT"; then
|
||||
echo " [5b/7] wrapper correct"
|
||||
else
|
||||
if [ -f "$WRAPPER_PATH" ]; then
|
||||
if [ ! -f "${WRAPPER_PATH}.orig" ]; then
|
||||
cp "$WRAPPER_PATH" "${WRAPPER_PATH}.orig"
|
||||
echo " [5b/7] backed up original wrapper → ${WRAPPER_PATH}.orig"
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$WRAPPER_CONTENT" > "$WRAPPER_PATH"
|
||||
chmod +x "$WRAPPER_PATH"
|
||||
echo " [5b/7] generated wrapper (delegates → python memory CLI)"
|
||||
fi
|
||||
|
||||
# 6. status
|
||||
echo " [7/7] done"
|
||||
echo ""
|
||||
echo "memory: ready at $MEMORY_DIR"
|
||||
echo " remote: $REMOTE"
|
||||
echo " branch: $BRANCH"
|
||||
echo " auto-push: enabled (post-commit hook)"
|
||||
|
|
@ -6,17 +6,17 @@ description: Инструкция по работе с файловой памя
|
|||
# File Memory (opencode-memory)
|
||||
|
||||
Графовая память (Graphiti/FalkorDB) удалена — была нестабильна и забагована.
|
||||
Теперь память работает через `@mathew-cf/opencode-memory` — файловая система с keyword + semantic search.
|
||||
Память работает через файловую систему с keyword + semantic search — 5 TS tools (`.opencode/tools/memory-*.ts`), вызывают `python3 -m src.memory`. Плагин `@mathew-cf/opencode-memory` удалён.
|
||||
|
||||
## Инструменты
|
||||
|
||||
| Инструмент | Назначение |
|
||||
|---|---|
|
||||
| `memory_search(query, category?)` | Гибридный поиск (keyword + semantic) |
|
||||
| `memory_list(category?)` | Список категорий / файлов |
|
||||
| `memory_save()` | Commit + re-index после записи/редактирования |
|
||||
| `memory_access(path)` | Отметить файл как прочитанный |
|
||||
| `memory-setup()` | Проверить статус бэкендов |
|
||||
| `memory-search({ query, category? })` | Гибридный поиск (ripgrep keyword + Python semantic) |
|
||||
| `memory-list({ category? })` | Список категорий / файлов |
|
||||
| `memory-save()` | Commit + reindex после записи/редактирования (auto-setup: git init + hook если `OPENCODE_MEMORY_REMOTE` set) |
|
||||
| `memory-access({ path })` | Отметить файл как прочитанный (bump `last_accessed`/`access_count`) |
|
||||
| `memory-doctor()` | Read-only диагностика: ripgrep, Python `src.memory`, env vars, index |
|
||||
|
||||
## Категории
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ description: Инструкция по работе с файловой памя
|
|||
|
||||
## Как работать
|
||||
|
||||
1. **Перед началом работы** — `memory_search` по теме
|
||||
1. **Перед началом работы** — `memory-search` по теме
|
||||
2. **В процессе** — сохранять находки сразу (контекст свежий)
|
||||
3. **В конце сессии** — retrospective: что узнал → сохранить, что было в памяти → обновить, чего не хватало → создать
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ related: [category/file.md]
|
|||
|
||||
## Путь для репозиториев
|
||||
|
||||
Плагинный default: `~/opencode-memory` (переопределяется через `OPENCODE_MEMORY_DIR`, например `app_data/opencode-memory`).
|
||||
Default: `/root/.local/share/opencode/opencode-memory` (переопределяется через `OPENCODE_MEMORY_DIR`).
|
||||
|
||||
```
|
||||
{memory-dir}/repos/{host}/{org}/{repo}.md
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
import { spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
export default tool({
|
||||
description: "Initialize or sync opencode-memory. Clones remote repo, installs post-commit hook for auto-push, rebuilds RAG index. Idempotent — safe to run multiple times. No arguments needed.",
|
||||
args: {},
|
||||
async execute(_args, context) {
|
||||
const script = path.join(import.meta.dir, "..", "scripts", "setup-memory.sh")
|
||||
const r = spawnSync("bash", [script], {
|
||||
encoding: "utf-8",
|
||||
cwd: context.worktree,
|
||||
})
|
||||
if (r.status !== 0) {
|
||||
return `⚠️ memory-setup failed (exit ${r.status}): ${r.stderr || r.stdout}`
|
||||
}
|
||||
return r.stdout.trim()
|
||||
},
|
||||
})
|
||||
|
|
@ -35,7 +35,11 @@ fallback на raw bash, НЕ импровизируй обход через `gh
|
|||
| `post-docs-review({ pr_number, verdict, body })` | `gh pr comment` для verdict | Docs review verdict (heading `## Docs Review Summary` + verdict) | Сообщи оркестратору, не `gh pr comment` |
|
||||
| `pipeline-status({ pr_number })` | `python3 .opencode/scripts/pipeline-status.py` | Read-only oracle: статус фаз PR + NEXT action | Сообщи оркестратору, не bash-запуск скрипта |
|
||||
| `spec-status({})` | `python3 .opencode/scripts/spec-status.py` | Read-only oracle: текущая фаза spec + NEXT action | Сообщи оркестратору, не bash-запуск скрипта |
|
||||
| `memory-setup()` | `bash .opencode/scripts/setup-memory.sh` | Инициализация/синхронизация opencode-memory (clone + hook + reindex) | Сообщи оркестратору, не bash-запуск скрипта |
|
||||
| `memory-doctor()` | — | Read-only диагностика памяти: ripgrep, Python src.memory, env vars, index (замена `memory-setup`) | Сообщи оркестратору, не raw bash |
|
||||
| `memory-save()` | — | Commit + reindex opencode-memory после записи/редактирования (auto-setup: git init + hook если remote set) | Сообщи оркестратору, не raw bash |
|
||||
| `memory-search({ query, category? })` | — | Гибридный поиск (ripgrep keyword + Python semantic) по opencode-memory | Сообщи оркестратору, не raw bash |
|
||||
| `memory-list({ category? })` | — | Список категорий / файлов opencode-memory | Сообщи оркестратору, не raw bash |
|
||||
| `memory-access({ path })` | — | Отметить memory-файл как прочитанный (bump `last_accessed`/`access_count`) | Сообщи оркестратору, не raw bash |
|
||||
| `tunnel()` | `bash .opencode/scripts/tunnel.sh` | Cloudflare tunnel toggle (1-й вызов — start, 2-й — stop) | Сообщи оркестратору, не bash-запуск скрипта |
|
||||
|
||||
`gh pr comment*` остаётся в allow-list для обратной совместимости (ADR-019
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ RUN curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/c
|
|||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
|
||||
RUN npm install -g opencode-ai repomix @mathew-cf/opencode-memory --unsafe-perm
|
||||
RUN npm install -g opencode-ai repomix --unsafe-perm
|
||||
|
||||
WORKDIR /root/workspace
|
||||
|
||||
|
|
|
|||
63
docs/decisions/045-pr-103-remove-plugin-rust.md
Normal file
63
docs/decisions/045-pr-103-remove-plugin-rust.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# ADR-045: Remove @mathew-cf/opencode-memory plugin + Rust artifacts
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted (2026-07-26)
|
||||
|
||||
## Контекст
|
||||
|
||||
После миграции на единую гибридную систему памяти (5 TS tools, PR #101) + E2E
|
||||
покрытия (PR #102) сохранилась двойная система:
|
||||
|
||||
- **Plugin `@mathew-cf/opencode-memory`** прописан в `opencode.json` блоке
|
||||
`plugin` и устанавливается в `Dockerfile` через `npm install -g`. Но plugin
|
||||
не вызывается из-за бага wrapper-пути (см. ADR-043, handoff pr-101) —
|
||||
фактически мёртвый код, съедающий ~85 MB в image + cache.
|
||||
- **Rust `rag-cli` binary** (внутри плагина) пишет `.rag/index.bin` (7.5 MB) +
|
||||
`.rag/meta.json` (12 KB) в Rust-формате (`hidden_size`, `model_id`,
|
||||
`file_hashes`). Python `src/memory/index.py` пишет `.rag/index.json` (210 MB,
|
||||
4096-dim embeddings) + `.rag/meta.json` в Python-формате (`version`, `files`).
|
||||
**Конфликт**: оба пишут `meta.json` по одному пути, разные форматы → Rust
|
||||
перетирает Python → broken индекс при сосуществовании.
|
||||
- **`setup-memory.sh`** (4.6 KB) — shell-скрипт инициализации, заменён на
|
||||
auto-setup внутри `memory-save.ts` (git init + hook если remote set).
|
||||
- **`memory-setup.ts`** (716 B) — TS tool-обёртка над `setup-memory.sh`,
|
||||
заменён на `memory-doctor.ts` (read-only диагностика, не модифицирующий).
|
||||
|
||||
Cleanup нужно делать ПОСЛЕ подтверждения что TS tools работают (PR #101) и E2E
|
||||
тесты проходят (PR #102) — иначе остались бы без памяти. Issue #97 (E2E green)
|
||||
был blocker для #98.
|
||||
|
||||
## Решение
|
||||
|
||||
Удалить все артефакты старой системы за один PR (issue #98, фаза 3 эпика #94):
|
||||
|
||||
1. `opencode.json` — убрать блок `plugin` целиком. JSON остался валидным.
|
||||
2. `Dockerfile` — убрать `@mathew-cf/opencode-memory` из `npm install -g`.
|
||||
3. `.opencode/scripts/setup-memory.sh` — удалить (auto-setup в `memory-save.ts`).
|
||||
4. `.opencode/tools/memory-setup.ts` — удалить (заменён на `memory-doctor.ts`).
|
||||
5. Rust артефакты на хосте: `.rag/index.bin`, `.rag/meta.json` (Rust-формат),
|
||||
`~/.cache/opencode/packages/@mathew-cf/opencode-memory@latest/` + sibling
|
||||
`opencode-memory/` (оба 85 MB). НЕ трогать `.rag/index.json` (Python).
|
||||
6. `AGENTS.md` + `SKILL.md` — обновить Tool Usage Policy: убрать
|
||||
`memory-setup()`, добавить 5 kebab-case tools (`memory-doctor`, `memory-save`,
|
||||
`memory-search`, `memory-list`, `memory-access`).
|
||||
7. Тесты: инвертировать assertions `test_dockerfile.py` (плагин должен
|
||||
ОТСУТСТВОВАТЬ), удалить тесты для `setup-memory.sh` и `memory-setup.ts`.
|
||||
|
||||
Имя tool-casing: **kebab-case** для локальных `.opencode/tools/*.ts` (runtime
|
||||
registry = filename), confirmed ADR-009 + memory `tool-name-casing-kebab-vs-mcp-snake`.
|
||||
MCP plugin tools были snake_case, но плагин удалён — больше не актуально.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **Оставить plugin как fallback** — отклонено: plugin не вызывается из-за
|
||||
wrapper-бага, Rust meta.json конфилктует с Python, ~170 MB мёртвого веса.
|
||||
Двойная система = каша, двойной maintenance, риск что Rust перетрёт Python
|
||||
индекс.
|
||||
- **Удалить только Rust артефакты, оставить plugin в конфиге** — отклонено:
|
||||
plugin без `npm install` = silent skip (opencode логирует warning), но
|
||||
оставляет мёртвый блок в конфиге + вводит в заблуждение.
|
||||
- **Поэтапный cleanup (несколько PR)** — отклонено: артефакты тесно связаны
|
||||
(plugin → setup-memory.sh → memory-setup.ts → Rust binary), частичный cleanup
|
||||
оставляет поломанное состояние.
|
||||
86
docs/handoff/pr-103-remove-plugin-rust.md
Normal file
86
docs/handoff/pr-103-remove-plugin-rust.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
pr: 103
|
||||
title: Remove @mathew-cf/opencode-memory plugin + setup-memory.sh + Rust artifacts
|
||||
---
|
||||
|
||||
## Что сделано
|
||||
|
||||
Cleanup двойной системы памяти после перехода на единую гибридную (5 TS tools
|
||||
PR #101 + E2E PR #102). Удалено всё, что связано со старой plugin-based системой:
|
||||
|
||||
1. **`opencode.json`** — убран блок `plugin` с `@mathew-cf/opencode-memory`
|
||||
(строки 3-10). JSON остался валидным.
|
||||
2. **`Dockerfile:34`** — `@mathew-cf/opencode-memory` убран из `npm install -g`
|
||||
(`opencode-ai repomix --unsafe-perm`).
|
||||
3. **Удалены файлы**:
|
||||
- `.opencode/scripts/setup-memory.sh` — заменён на auto-setup в `memory-save.ts`
|
||||
- `.opencode/tools/memory-setup.ts` — заменён на `memory-doctor.ts` (read-only)
|
||||
4. **Rust артефакты на хосте** (не в git):
|
||||
- `~/.local/share/opencode/opencode-memory/.rag/index.bin` (7.5 MB, Rust binary)
|
||||
- `~/.local/share/opencode/opencode-memory/.rag/meta.json` (12 KB, Rust формат
|
||||
`hidden_size`/`model_id`/`file_hashes` — перетёр бы Python `version`/`files`)
|
||||
- `~/.cache/opencode/packages/@mathew-cf/opencode-memory@latest/` (85 MB)
|
||||
- `~/.cache/opencode/packages/@mathew-cf/opencode-memory/` (85 MB, sibling без
|
||||
`@latest` — тот же плагин, удалён для полноты cleanup)
|
||||
- Итого освобождено: ~177.5 MB
|
||||
- Python `index.json` (210 MB, 4096-dim embeddings) НЕ тронут.
|
||||
5. **`AGENTS.md`** (project) — таблица Tool Usage Policy обновлена: убран
|
||||
`memory-setup()`, добавлены `memory-doctor`, `memory-save`, `memory-search`,
|
||||
`memory-list`, `memory-access` (kebab-case для TS tools).
|
||||
6. **`SKILL.md`** (`.opencode/skills/memory/`) — обновлены имена tools
|
||||
(`memory_search` → `memory-search`), описание (плагин удалён, 5 TS tools),
|
||||
default путь памяти (`/root/.local/share/opencode/opencode-memory`).
|
||||
7. **Тесты** адаптированы под cleanup:
|
||||
- `tests/test_dockerfile.py` — assertions инвертированы: плагин должен
|
||||
ОТСУТСТВОВАТЬ в Dockerfile (issue #98). Regression-guards для `opencode-ai`
|
||||
и `repomix` сохранены.
|
||||
- Удалены `tests/test_setup_memory.py`, `tests/test_memory_setup_tool.py`,
|
||||
`tests/test_memory_setup_tool.ts` — тесты для удалённых `setup-memory.sh`
|
||||
и `memory-setup.ts`.
|
||||
- `tests/test_commit_tool.py` — docstring обновлён (ссылка на `memory-setup.ts`
|
||||
заменена на `spec-status.ts`).
|
||||
|
||||
## Почему
|
||||
|
||||
Двойная система (plugin + 5 TS tools) = каша. Plugin `@mathew-cf/opencode-memory`
|
||||
не вызывался из-за бага wrapper-пути, Rust `rag-cli` binary конфликтовал с Python
|
||||
`index.json` (разные форматы `meta.json` по одному пути). Cleanup ПОСЛЕ того как
|
||||
5 TS tools работают (PR #101) и E2E тесты проходят (PR #102) — иначе остались бы
|
||||
без памяти.
|
||||
|
||||
Часть эпика #94 (migration plan), фаза 3 (cleanup). Blocked by #97 (E2E green).
|
||||
|
||||
## Pending
|
||||
|
||||
- Глобальный `/root/.config/opencode/AGENTS.md` и
|
||||
`/root/.config/opencode/skills/memory/SKILL.md` НЕ обновлены — Write tool
|
||||
заблокирован для путей вне workspace (`FileSystem.writeFile` error). Это
|
||||
ручная задача пользователя (копии тех же правок, что в project-файлах).
|
||||
- `OPENAI_EMBEDDING_BATCH_DELAY` НЕ добавлен в `.env.example` — этот env var
|
||||
НЕ существует в коде (`src/memory/embedder.py` использует только
|
||||
`OPENAI_EMBEDDING_BATCH_SIZE` + `Retry-After` из 429-ответа). Добавление
|
||||
мусорной переменной противоречит правилу «не добавлять мёртвый код». Issue #98
|
||||
задача 8 ссылается на «аудит» — вероятно устаревшая/ошибочная находка.
|
||||
- `~/.local/share/opencode/opencode-memory/.gitignore` — отсутствует. Это
|
||||
локальный репозиторий памяти (отдельный git), `.gitignore` для `.rag/` там
|
||||
не относится к этому PR — задача 5 issue #98, но не блокер (`.rag/` уже не
|
||||
содержит Rust артефактов).
|
||||
|
||||
## Watch out
|
||||
|
||||
- **Кеш `@mathew-cf/opencode-memory/` (без `@latest`)** — кроме `@latest/`
|
||||
существовал sibling-алиас `opencode-memory/` (85 MB, тот же плагин). Удалён
|
||||
тоже — иначе кеш не полностью очищен. Issue #98 явно указывает только
|
||||
`@latest/`, но критерий приёмки «Cache удалён» подразумевает полный cleanup.
|
||||
- **meta.json** — Python `index.py` и Rust `rag-cli` пишут `meta.json` в ОДИН
|
||||
путь (`<output>/.rag/meta.json`) но в РАЗНЫХ форматах. Rust: `{model_id,
|
||||
hidden_size, num_chunks, file_hashes}`. Python: `{version, files:{rel:{sha256,
|
||||
chunks}}}`. Текущий был Rust-формат → удалён; Python пересоздаст при следующем
|
||||
reindex. Если снова поставить плагин — он перетрёт Python meta → broken.
|
||||
- **Тест `test_memory_plugin_in_npm_install`** переименован в
|
||||
`test_memory_plugin_removed_from_npm_install` (assertion инвертирован). Если
|
||||
кто-то откатит cleanup — тест провалится с понятным сообщением.
|
||||
- **Plugin auto-load** — после удаления блока `plugin` из `opencode.json`
|
||||
плагин НЕ грузится при старте opencode (критерий #98 выполнен).
|
||||
- **`memory-doctor` tool** — после cleanup НЕ репортит проблем с plugin/Rust
|
||||
(он их и не проверял — проверяет ripgrep/Python/env/index, которые intact).
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
opencode-config — Docker-based AI coding assistant with persistent memory (opencode configuration). Runs in Docker via `docker-compose.yml` (dind + opencode services).
|
||||
|
||||
Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents) + global rules (pipeline, code style, language RU) + `## Tool Usage Policy` (таблица 10 tools: commit/create-pr/create-issue/merge-pr/post-review/post-docs-review/pipeline-status/spec-status/memory-setup/tunnel; raw bash заблокирован deny, при сбое tool — STOP, НЕ fallback). Auto-loaded for project + bind-mounted globally in container — PR#31, PR#63.
|
||||
Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents) + global rules (pipeline, code style, language RU) + `## Tool Usage Policy` (таблица tools: commit/create-pr/create-issue/merge-pr/post-review/post-docs-review/pipeline-status/spec-status/memory-doctor/memory-save/memory-search/memory-list/memory-access/tunnel; raw bash заблокирован deny, при сбое tool — STOP, НЕ fallback; `memory-setup` REMOVED PR#103). Auto-loaded for project + bind-mounted globally in container — PR#31, PR#63.
|
||||
|
||||
## Structure
|
||||
|
||||
|
|
@ -49,7 +49,6 @@ opencode-config/
|
|||
│ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101
|
||||
│ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101
|
||||
│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback) — PR#101
|
||||
│ │ ├── memory-setup.ts # memory-setup tool wrapper (0 args, calls setup-memory.sh) — PR#36
|
||||
│ │ ├── pipeline-status.ts # pipeline-status tool wrapper
|
||||
│ │ ├── post-docs-review.ts # post-docs-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Docs Review Summary heading; optional repo?: string) — PR#46, PR#65
|
||||
│ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65
|
||||
|
|
@ -61,10 +60,9 @@ opencode-config/
|
|||
│ │ ├── observability.py # OTel spans for tools
|
||||
│ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling, NEXT_ACTIONS with subagent_type+template) — PR#42, PR#67 (check_project_map guard)
|
||||
│ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs
|
||||
│ │ ├── setup-memory.sh # opencode-memory bootstrap (7-step: mkdir/clone/remote/hook/memory index/JS wrapper/status, idempotent) — PR#36, PR#77
|
||||
│ │ ├── spec-status.py # 9-phase spec oracle
|
||||
│ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34
|
||||
│ ├── opencode.json # MCP servers, providers, permissions, agents (role-based tools), plugins — PR#40, PR#69
|
||||
│ ├── opencode.json # MCP servers, providers, permissions, agents (role-based tools) — PR#40, PR#69 (plugin block REMOVED PR#103)
|
||||
│ ├── package.json # npm deps for tools/*.ts (deps: @vscode/ripgrep; devDeps: @types/node, typescript) — PR#101
|
||||
│ └── .gitignore # Ignores node_modules, etc.
|
||||
├── docs/
|
||||
|
|
@ -86,7 +84,7 @@ opencode-config/
|
|||
│ ├── test_check_permissions.py # permissions-check.yml validator
|
||||
│ ├── test_cli.py # src/memory/cli.py
|
||||
│ ├── test_docker_compose.py # docker-compose.yml port exposure (0.0.0.0:4096, no 127.0.0.1) — PR#51
|
||||
│ ├── test_dockerfile.py # Dockerfile npm install (opencode-ai + repomix + @mathew-cf/opencode-memory) — PR#57
|
||||
│ ├── test_dockerfile.py # Dockerfile npm install (opencode-ai + repomix; @mathew-cf/opencode-memory REMOVED PR#103, assertions inverted) — PR#57, PR#103
|
||||
│ ├── test_commit_tool.py # .opencode/tools/commit.ts (via _ts_loader.mjs exec_stub_json) — PR#38
|
||||
│ ├── test_commit_tool.ts # TS wrapper test (mjs loader) — PR#38
|
||||
│ ├── test_create_issue_tool.py # .opencode/tools/create-issue.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65
|
||||
|
|
@ -97,8 +95,6 @@ opencode-config/
|
|||
│ ├── test_embedder_live.py # Live embed tests (skip without RUN_LIVE=1)
|
||||
│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip)
|
||||
│ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap)
|
||||
│ ├── test_memory_setup_tool.py # .opencode/tools/memory-setup.ts (via _ts_loader.mjs) — PR#36
|
||||
│ ├── test_memory_setup_tool.ts # TS wrapper test (mjs loader) — PR#36
|
||||
│ ├── test_memory_tools_e2e.py # E2E hybrid memory (4 scenarios: zero-config keyword-only, full hybrid, OpenRouter fallback, deletion; @pytest.mark.skipif(not RUN_LIVE); subprocess-based: ripgrep + python3 -m src.memory) — PR#102
|
||||
│ ├── test_merge_pr_tool.py # .opencode/tools/merge-pr.ts (via _ts_loader.mjs; 8 tests: base + repo explicit/omitted/invalid) — PR#65
|
||||
│ ├── test_merge_pr_tool.ts # TS wrapper test (mjs loader; 5 tests: base + repo) — PR#65
|
||||
|
|
@ -116,7 +112,6 @@ opencode-config/
|
|||
│ ├── test_post_review_tool.py # .opencode/tools/post-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65
|
||||
│ ├── test_post_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65
|
||||
│ ├── test_search.py # src/memory/search.py
|
||||
│ ├── test_setup_memory.py # .opencode/scripts/setup-memory.sh (mock remote, idempotency, JS wrapper gen/backup) — PR#36, PR#77
|
||||
│ ├── test_spec_status.py # .opencode/scripts/spec-status.py
|
||||
│ ├── test_spec_status_tool.py
|
||||
│ ├── test_tunnel_tool.py # .opencode/scripts/tunnel.sh (6 pytest: start/stop/stale PID/toggle, isolated tmp copy + fake cloudflared) — PR#65
|
||||
|
|
@ -125,7 +120,7 @@ opencode-config/
|
|||
├── uv.lock # Locked deps for Python project
|
||||
├── .pre-commit-config.yaml # ruff + UV hooks
|
||||
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0 — PR#24, PR#51
|
||||
├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + @mathew-cf/opencode-memory + cloudflared — PR#24, PR#34, PR#57, PR#71
|
||||
├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103
|
||||
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR)
|
||||
├── app_data/
|
||||
│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ def test_wrong_type():
|
|||
def test_execute_uses_cwd_from_context():
|
||||
"""execute passes cwd=context.worktree to spawnSync (ADR-023 pattern).
|
||||
|
||||
Like pipeline-status.ts / memory-setup.ts, the commit tool wrapper
|
||||
Like pipeline-status.ts / spec-status.ts, the commit tool wrapper
|
||||
propagates ``context.worktree`` as ``cwd`` to spawnSync.
|
||||
"""
|
||||
out = _run_exec({"message": VALID_MSG}, [STAGED_RESPONSE, COMMIT_OK_RESPONSE])
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""Tests for Dockerfile npm install line.
|
||||
|
||||
Covers issue #56 acceptance criteria:
|
||||
- ``Dockerfile`` installs ``@mathew-cf/opencode-memory`` alongside
|
||||
``opencode-ai`` and ``repomix`` in the global npm install step, so the
|
||||
memory plugin (``memory_save`` / ``memory_search`` / ``memory_list`` MCP
|
||||
tools) is available inside the container.
|
||||
- ``opencode-ai`` is still present (regression guard — must not be dropped
|
||||
when editing the install line).
|
||||
Covers issue #98 acceptance criteria:
|
||||
- ``Dockerfile`` no longer installs ``@mathew-cf/opencode-memory`` — the
|
||||
plugin was replaced by 5 TS tools (``.opencode/tools/memory-*.ts``) that
|
||||
call ``python3 -m src.memory`` directly (PR #101). The npm package is
|
||||
dead weight in the image and is removed from the global install step.
|
||||
- ``opencode-ai`` and ``repomix`` remain (regression guard — must not be
|
||||
dropped when editing the install line).
|
||||
|
||||
Checks are raw-text grep assertions (no Docker build needed), mirroring the
|
||||
belt-and-suspenders style of ``test_docker_compose.py``.
|
||||
|
|
@ -17,8 +17,8 @@ from pathlib import Path
|
|||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DOCKERFILE = REPO_ROOT / "Dockerfile"
|
||||
|
||||
REQUIRED_PACKAGES = ("opencode-ai", "repomix", "@mathew-cf/opencode-memory")
|
||||
MEMORY_PLUGIN = "@mathew-cf/opencode-memory"
|
||||
REMOVED_PLUGIN = "@mathew-cf/opencode-memory"
|
||||
KEPT_PACKAGES = ("opencode-ai", "repomix")
|
||||
OPENCODE_AI = "opencode-ai"
|
||||
|
||||
|
||||
|
|
@ -41,33 +41,33 @@ def test_dockerfile_exists():
|
|||
assert DOCKERFILE.exists(), f"Dockerfile missing: {DOCKERFILE}"
|
||||
|
||||
|
||||
# ── @mathew-cf/opencode-memory installed (issue #56 — the required plugin) ───
|
||||
# ── @mathew-cf/opencode-memory removed (issue #98 — plugin replaced by TS tools)
|
||||
|
||||
|
||||
def test_memory_plugin_in_npm_install():
|
||||
"""Dockerfile installs ``@mathew-cf/opencode-memory`` via ``npm install -g``.
|
||||
def test_memory_plugin_removed_from_npm_install():
|
||||
"""Dockerfile does NOT install ``@mathew-cf/opencode-memory`` via ``npm install -g``.
|
||||
|
||||
Without the plugin the memory MCP tools (``memory_save`` /
|
||||
``memory_search`` / ``memory_list``) are unavailable — opencode silently
|
||||
skips the uninstalled plugin referenced in opencode.json.
|
||||
The plugin was replaced by 5 TS tools (PR #101) that call
|
||||
``python3 -m src.memory`` directly — the npm package is no longer needed
|
||||
inside the container and is removed from the global install step.
|
||||
"""
|
||||
lines = _npm_install_lines(_dockerfile_text())
|
||||
assert lines, "no `npm install -g` line found in Dockerfile"
|
||||
assert any(MEMORY_PLUGIN in line for line in lines), (
|
||||
f"Dockerfile must install {MEMORY_PLUGIN!r} via `npm install -g` — "
|
||||
"memory plugin MCP tools depend on it"
|
||||
assert not any(REMOVED_PLUGIN in line for line in lines), (
|
||||
f"Dockerfile must NOT install {REMOVED_PLUGIN!r} via `npm install -g` — "
|
||||
"plugin replaced by TS tools (PR #101, cleanup issue #98)"
|
||||
)
|
||||
|
||||
|
||||
def test_memory_plugin_in_npm_install_raw():
|
||||
"""Raw text check: the Dockerfile contains ``@mathew-cf/opencode-memory``.
|
||||
def test_memory_plugin_removed_from_npm_install_raw():
|
||||
"""Raw text check: the Dockerfile does NOT contain ``@mathew-cf/opencode-memory``.
|
||||
|
||||
Belt-and-suspenders alongside the parsed check — catches edge cases where
|
||||
the line collector might miss a multi-line or differently-formatted
|
||||
install statement.
|
||||
Belt-and-suspenders alongside the parsed check — catches any lingering
|
||||
reference to the removed plugin (multi-line install, comment, etc.).
|
||||
"""
|
||||
assert MEMORY_PLUGIN in _dockerfile_text(), (
|
||||
f"Dockerfile must contain {MEMORY_PLUGIN!r} so the memory plugin loads inside the container"
|
||||
assert REMOVED_PLUGIN not in _dockerfile_text(), (
|
||||
f"Dockerfile must NOT contain {REMOVED_PLUGIN!r} — plugin replaced by "
|
||||
"TS tools (PR #101, cleanup issue #98)"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ def test_memory_plugin_in_npm_install_raw():
|
|||
def test_opencode_ai_present():
|
||||
"""Dockerfile still installs ``opencode-ai`` (regression guard).
|
||||
|
||||
Editing the npm install line to add the memory plugin must not
|
||||
Removing the memory plugin from the npm install line must not
|
||||
accidentally drop the primary ``opencode-ai`` package.
|
||||
"""
|
||||
lines = _npm_install_lines(_dockerfile_text())
|
||||
|
|
@ -93,11 +93,11 @@ def test_opencode_ai_present_raw():
|
|||
assert OPENCODE_AI in _dockerfile_text(), f"Dockerfile must still contain {OPENCODE_AI!r}"
|
||||
|
||||
|
||||
# ── single npm install line holds all three packages ────────────────────────
|
||||
# ── single npm install line holds the kept packages ──────────────────────────
|
||||
|
||||
|
||||
def test_single_npm_install_line_has_all_packages():
|
||||
"""One ``npm install -g`` line installs all three required packages.
|
||||
def test_single_npm_install_line_has_kept_packages():
|
||||
"""One ``npm install -g`` line installs all kept packages.
|
||||
|
||||
Guards against splitting the install across multiple RUN layers (which
|
||||
would waste image layers and obscure which package is missing).
|
||||
|
|
@ -106,7 +106,7 @@ def test_single_npm_install_line_has_all_packages():
|
|||
install_lines = [line for line in lines if OPENCODE_AI in line]
|
||||
assert install_lines, f"no `npm install -g` line with {OPENCODE_AI!r}"
|
||||
main_line = install_lines[0]
|
||||
for pkg in REQUIRED_PACKAGES:
|
||||
for pkg in KEPT_PACKAGES:
|
||||
assert pkg in main_line, (
|
||||
f"package {pkg!r} missing from the opencode-ai npm install line: {main_line!r}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
"""Tests for .opencode/tools/memory-setup.ts — the memory-setup custom tool.
|
||||
|
||||
Mirrors ``tests/test_pipeline_status_tool.py`` / ``test_spec_status_tool.py``:
|
||||
exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs``
|
||||
(a node CommonJS sandbox that strips TS-only syntax, stubs
|
||||
``@opencode-ai/plugin``, and replaces ``import.meta.dir`` with the real
|
||||
``.opencode/tools`` directory).
|
||||
|
||||
The loader is parameterized via the ``TS_FILE`` env var so the same harness
|
||||
works for every TS tool wrapper. These tests set
|
||||
``TS_FILE=.opencode/tools/memory-setup.ts``.
|
||||
|
||||
Modes used (same as the pipeline-status / spec-status tool tests):
|
||||
- ``load`` — sanity-check that the tool loads and declares no args.
|
||||
- ``exec_stub`` — call execute with a stubbed spawnSync to verify:
|
||||
(a) success path: exit 0 → trimmed stdout returned,
|
||||
(b) failure path: exit 1 → ``⚠️ memory-setup failed (exit 1): <stderr>``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
|
||||
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "memory-setup.ts"
|
||||
TS_FILE_REL = ".opencode/tools/memory-setup.ts"
|
||||
|
||||
|
||||
def _run_loader(*args: str) -> dict:
|
||||
"""Invoke the loader with ``TS_FILE`` env set to memory-setup.ts and parse JSON stdout."""
|
||||
env = {**os.environ, "TS_FILE": TS_FILE_REL}
|
||||
proc = subprocess.run(
|
||||
["node", str(LOADER), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=60,
|
||||
env=env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n"
|
||||
f"stdout: {proc.stdout}\nstderr: {proc.stderr}"
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_loader_can_load_tool():
|
||||
"""Sanity: memory-setup.ts loads and declares no args (toggle-style tool)."""
|
||||
if not TS_FILE.exists():
|
||||
pytest.skip("memory-setup.ts not present")
|
||||
out = _run_loader("load")
|
||||
assert "description" in out
|
||||
assert out["args"] == [], f"expected no args, got: {out['args']}"
|
||||
|
||||
|
||||
def test_success():
|
||||
"""execute() with exit 0 returns trimmed stdout.
|
||||
|
||||
memory-setup.ts follows the tunnel.ts pattern: ``r.stdout.trim()`` on
|
||||
success. Stub spawnSync to return ``" memory: ready \\n"`` and verify
|
||||
the tool returns ``"memory: ready"`` (whitespace stripped).
|
||||
"""
|
||||
out = _run_loader("exec_stub", "_", "0", " memory: ready \n", "")
|
||||
result = out["result"]
|
||||
assert result == "memory: ready", f"expected trimmed stdout, got: {result!r}"
|
||||
|
||||
|
||||
def test_failure():
|
||||
"""execute() with exit 1 returns ``⚠️ memory-setup failed (exit 1): <stderr>``.
|
||||
|
||||
The tool prepends ``⚠️ memory-setup failed (exit <N>): `` to the stderr
|
||||
(falling back to stdout if stderr is empty). Stub spawnSync to return
|
||||
exit 1 + a stderr string and verify the error prefix + stderr content.
|
||||
"""
|
||||
out = _run_loader("exec_stub", "_", "1", "", "ERROR: OPENCODE_MEMORY_REMOTE not set")
|
||||
result = out["result"]
|
||||
assert "⚠️ memory-setup failed" in result, f"missing error prefix: {result!r}"
|
||||
assert "exit 1" in result, f"missing exit code: {result!r}"
|
||||
assert "OPENCODE_MEMORY_REMOTE not set" in result, f"missing stderr: {result!r}"
|
||||
|
||||
|
||||
def test_execute_uses_cwd_from_context():
|
||||
"""execute passes ``cwd=context.worktree`` to spawnSync (ADR-023 pattern).
|
||||
|
||||
Like pipeline-status.ts / spec-status.ts / tunnel.ts, the memory-setup
|
||||
tool wrapper propagates ``context.worktree`` as ``cwd`` to spawnSync so
|
||||
the bash script runs in the git worktree root (where .opencode/ lives),
|
||||
not in the opencode process cwd.
|
||||
"""
|
||||
out = _run_loader("exec_stub", "_", "0", "ok", "")
|
||||
calls = out["calls"]
|
||||
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
|
||||
opts = calls[0]["opts"]
|
||||
assert opts is not None, "spawnSync called without opts — expected cwd kwarg"
|
||||
assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}"
|
||||
assert opts["cwd"] == str(REPO_ROOT), (
|
||||
f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}"
|
||||
)
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
/**
|
||||
* Tests for .opencode/tools/memory-setup.ts — the memory-setup custom tool.
|
||||
*
|
||||
* Mirror of tests/test_pipeline_status_tool.ts / test_spec_status_tool.ts:
|
||||
* the tool is a thin spawnSync wrapper around
|
||||
* ``.opencode/scripts/setup-memory.sh`` with no args.
|
||||
*
|
||||
* Runtime note: opencode ships a standalone binary with Bun bundled inside;
|
||||
* there is no separate ``bun`` CLI on the host (CI runner uses node + pytest).
|
||||
* The CI runs the equivalent Python tests in
|
||||
* ``tests/test_memory_setup_tool.py`` via the JS loader ``tests/_ts_loader.mjs``.
|
||||
* This file documents the intended TS-side test cases and is runnable under
|
||||
* ``bun test`` once a bun runtime is available on the host.
|
||||
*
|
||||
* Test cases (mirror tests/test_memory_setup_tool.py):
|
||||
* - test_success — script exit 0 → returns trimmed stdout
|
||||
* - test_failure — script exit 1 → returns ``⚠️ memory-setup failed (exit 1): <stderr>``
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" }
|
||||
import { spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "memory-setup.ts")
|
||||
|
||||
describe("memory-setup tool", () => {
|
||||
test("test_success — script exit 0 returns trimmed stdout", async () => {
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: () => ({ status: 0, stdout: " memory: ready \n", stderr: "" }),
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({}, {
|
||||
sessionID: "t", messageID: "t", agent: "t",
|
||||
directory: ".", worktree: ".",
|
||||
abort: new AbortController().signal,
|
||||
metadata() {}, async ask() {},
|
||||
})
|
||||
expect(result).toBe("memory: ready")
|
||||
})
|
||||
|
||||
test("test_failure — script exit 1 returns error message", async () => {
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: () => ({ status: 1, stdout: "", stderr: "ERROR: OPENCODE_MEMORY_REMOTE not set" }),
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({}, {
|
||||
sessionID: "t", messageID: "t", agent: "t",
|
||||
directory: ".", worktree: ".",
|
||||
abort: new AbortController().signal,
|
||||
metadata() {}, async ask() {},
|
||||
})
|
||||
expect(result).toContain("⚠️ memory-setup failed")
|
||||
expect(result).toContain("exit 1")
|
||||
expect(result).toContain("OPENCODE_MEMORY_REMOTE not set")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
"""Tests for .opencode/scripts/setup-memory.sh — deterministic memory init.
|
||||
|
||||
Uses a local mock remote (``git init --bare``) instead of GitHub so the
|
||||
tests are hermetic and offline-safe. ``tmp_path`` provides isolation.
|
||||
|
||||
The script is invoked via ``subprocess`` with env vars pointing at the
|
||||
mock remote + a tmp dir. Each test asserts one of the deterministic
|
||||
flow steps.
|
||||
|
||||
Script contract (``setup-memory.sh``):
|
||||
1. mkdir -p MEMORY_DIR
|
||||
2. clone REMOTE | git pull --ff-only
|
||||
3. remote set-url if origin != REMOTE
|
||||
4. install post-commit hook (auto-push) if missing/wrong
|
||||
5. memory index if .rag missing (best-effort, CLI optional)
|
||||
5b. generate JS wrapper for opencode-memory plugin (idempotent + backup)
|
||||
6. echo status
|
||||
|
||||
Exit 1 when ``OPENCODE_MEMORY_REMOTE`` is unset (no default — the
|
||||
``.env.example`` provides the value; absence is a config error).
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / ".opencode" / "scripts" / "setup-memory.sh"
|
||||
REPO_PYTHON = str(REPO_ROOT / ".venv" / "bin" / "python")
|
||||
|
||||
EXPECTED_HOOK = "#!/bin/bash\ngit push origin master 2>/dev/null || true\n"
|
||||
|
||||
|
||||
def _seed_remote(remote_dir: Path) -> None:
|
||||
"""Seed a bare remote with one commit on ``master``."""
|
||||
remote_dir.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["git", "init", "--bare", "-q", str(remote_dir)], check=True)
|
||||
seed = remote_dir.parent / "seed"
|
||||
seed.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(seed)], check=True)
|
||||
(seed / "README.md").write_text("# memory\n")
|
||||
subprocess.run(["git", "-C", str(seed), "add", "README.md"], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(seed),
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(seed), "branch", "-M", "master"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(seed), "remote", "add", "origin", str(remote_dir)],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(seed), "push", "-q", "origin", "master"], check=True)
|
||||
|
||||
|
||||
def _run_script(
|
||||
memory_dir: Path,
|
||||
remote: str | None,
|
||||
wrapper: Path | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run setup-memory.sh with env pointing at tmp paths.
|
||||
|
||||
When ``remote`` is None, OPENCODE_MEMORY_REMOTE is removed from env
|
||||
(simulating the "no remote env" error path). ``wrapper`` sets
|
||||
``MEMORY_WRAPPER_PATH`` so tests don't write to the real plugin dir.
|
||||
"""
|
||||
env = {
|
||||
**os.environ,
|
||||
"OPENCODE_MEMORY_DIR": str(memory_dir),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
if remote is not None:
|
||||
env["OPENCODE_MEMORY_REMOTE"] = remote
|
||||
else:
|
||||
env.pop("OPENCODE_MEMORY_REMOTE", None)
|
||||
if wrapper is not None:
|
||||
env["MEMORY_WRAPPER_PATH"] = str(wrapper)
|
||||
wrapper.parent.mkdir(parents=True, exist_ok=True)
|
||||
env["MEMORY_WRAPPER_PYTHON"] = REPO_PYTHON
|
||||
env["OPENCODE_WORKSPACE"] = str(REPO_ROOT)
|
||||
return subprocess.run(
|
||||
["bash", str(SCRIPT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def test_fresh_init(tmp_path: Path) -> None:
|
||||
"""Empty dir → run → clone, hook, remote, files present."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js")
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
assert (mem / ".git").is_dir(), "repo not cloned"
|
||||
assert (mem / "README.md").exists(), "file not pulled"
|
||||
assert (mem / ".git" / "hooks" / "post-commit").exists(), "hook missing"
|
||||
got = subprocess.run(
|
||||
["git", "-C", str(mem), "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert got == str(remote), f"remote mismatch: {got}"
|
||||
|
||||
|
||||
def test_existing_repo(tmp_path: Path) -> None:
|
||||
""".git exists → run → pull --ff-only, no destructive changes."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
||||
# capture state before second run
|
||||
head_before = subprocess.run(
|
||||
["git", "-C", str(mem), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js")
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
head_after = subprocess.run(
|
||||
["git", "-C", str(mem), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert head_before == head_after, "pull --ff-only changed HEAD unexpectedly"
|
||||
assert "pulling latest" in r.stdout
|
||||
|
||||
|
||||
def test_wrong_remote(tmp_path: Path) -> None:
|
||||
"""remote ≠ expected → run → set-url corrects the remote."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
||||
# corrupt remote URL (tmp path — avoids S108 insecure-tmp warning)
|
||||
wrong = tmp_path / "wrong-remote"
|
||||
subprocess.run(
|
||||
["git", "-C", str(mem), "remote", "set-url", "origin", str(wrong)],
|
||||
check=True,
|
||||
)
|
||||
r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js")
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
assert "fixing remote" in r.stdout
|
||||
got = subprocess.run(
|
||||
["git", "-C", str(mem), "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert got == str(remote), f"remote not fixed: {got}"
|
||||
|
||||
|
||||
def test_missing_hook(tmp_path: Path) -> None:
|
||||
"""hook missing → run → hook created with correct content."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
||||
hook = mem / ".git" / "hooks" / "post-commit"
|
||||
hook.unlink()
|
||||
assert not hook.exists()
|
||||
r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js")
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
assert hook.exists(), "hook not recreated"
|
||||
assert hook.read_text() == EXPECTED_HOOK, f"hook content wrong: {hook.read_text()!r}"
|
||||
assert "installing post-commit hook" in r.stdout
|
||||
|
||||
|
||||
def test_idempotent(tmp_path: Path) -> None:
|
||||
"""run 3x → state identical after run 1 and run 3."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
|
||||
def _snapshot() -> dict[str, str | None]:
|
||||
head = subprocess.run(
|
||||
["git", "-C", str(mem), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
hook = mem / ".git" / "hooks" / "post-commit"
|
||||
return {
|
||||
"head": head,
|
||||
"hook": hook.read_text() if hook.exists() else None,
|
||||
"remote": subprocess.run(
|
||||
["git", "-C", str(mem), "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip(),
|
||||
}
|
||||
|
||||
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
||||
snap1 = _snapshot()
|
||||
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
||||
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
||||
snap3 = _snapshot()
|
||||
assert snap1 == snap3, f"non-idempotent:\n run1={snap1}\n run3={snap3}"
|
||||
|
||||
|
||||
def test_no_remote_env(tmp_path: Path) -> None:
|
||||
"""OPENCODE_MEMORY_REMOTE unset → exit 1, error message to stderr/stdout."""
|
||||
mem = tmp_path / "mem"
|
||||
r = _run_script(mem, remote=None)
|
||||
assert r.returncode == 1, f"expected exit 1, got {r.returncode}"
|
||||
combined = r.stderr + r.stdout
|
||||
assert "OPENCODE_MEMORY_REMOTE" in combined, f"missing error msg: {combined!r}"
|
||||
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",
|
||||
"MEMORY_WRAPPER_PATH": str(tmp_path / "wrapper" / "rag.js"),
|
||||
"MEMORY_WRAPPER_PYTHON": REPO_PYTHON,
|
||||
"OPENCODE_WORKSPACE": str(REPO_ROOT),
|
||||
}
|
||||
(tmp_path / "wrapper").mkdir(exist_ok=True)
|
||||
|
||||
# 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",
|
||||
"MEMORY_WRAPPER_PATH": str(tmp_path / "wrapper" / "rag.js"),
|
||||
"MEMORY_WRAPPER_PYTHON": REPO_PYTHON,
|
||||
"OPENCODE_WORKSPACE": str(REPO_ROOT),
|
||||
}
|
||||
env.pop("GITHUB_TOKEN", None)
|
||||
(tmp_path / "wrapper").mkdir(exist_ok=True)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ── JS wrapper for opencode-memory plugin (step 5b) ──────────────────────────
|
||||
|
||||
|
||||
def test_wrapper_generated(tmp_path: Path) -> None:
|
||||
"""Run → wrapper file exists at MEMORY_WRAPPER_PATH with delegate content."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
wrapper = tmp_path / "wrapper" / "rag.js"
|
||||
r = _run_script(mem, str(remote), wrapper=wrapper)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
assert wrapper.exists(), f"wrapper not generated at {wrapper}"
|
||||
assert wrapper.stat().st_mode & 0o100, "wrapper not executable"
|
||||
assert "generated wrapper" in r.stdout, f"missing generate msg: {r.stdout!r}"
|
||||
|
||||
|
||||
def test_wrapper_idempotent(tmp_path: Path) -> None:
|
||||
"""Run 2x → wrapper content identical, 2nd run reports 'wrapper correct'."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
wrapper = tmp_path / "wrapper" / "rag.js"
|
||||
r1 = _run_script(mem, str(remote), wrapper=wrapper)
|
||||
assert r1.returncode == 0, f"run1: stdout={r1.stdout}\nstderr={r1.stderr}"
|
||||
content1 = wrapper.read_text()
|
||||
r2 = _run_script(mem, str(remote), wrapper=wrapper)
|
||||
assert r2.returncode == 0, f"run2: stdout={r2.stdout}\nstderr={r2.stderr}"
|
||||
content2 = wrapper.read_text()
|
||||
assert content1 == content2, "wrapper content changed between runs"
|
||||
assert "wrapper correct" in r2.stdout, f"2nd run should report correct: {r2.stdout!r}"
|
||||
|
||||
|
||||
def test_wrapper_content(tmp_path: Path) -> None:
|
||||
"""Wrapper contains python memory CLI delegation + spawnSync."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
wrapper = tmp_path / "wrapper" / "rag.js"
|
||||
r = _run_script(mem, str(remote), wrapper=wrapper)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
body = wrapper.read_text()
|
||||
assert "python" in body, f"wrapper missing python ref: {body!r}"
|
||||
assert "-m" in body and "src.memory" in body, "wrapper missing src.memory module"
|
||||
assert "spawnSync" in body, "wrapper missing spawnSync"
|
||||
assert "process.argv.slice" in body, "wrapper missing argv passthrough"
|
||||
|
||||
|
||||
def test_wrapper_backup_original(tmp_path: Path) -> None:
|
||||
"""Pre-existing wrapper with different content → .orig backup saved once."""
|
||||
remote = tmp_path / "remote.git"
|
||||
_seed_remote(remote)
|
||||
mem = tmp_path / "mem"
|
||||
wrapper = tmp_path / "wrapper" / "rag.js"
|
||||
wrapper.parent.mkdir(parents=True, exist_ok=True)
|
||||
original = "# old rag-cli shim\nconsole.log('old');\n"
|
||||
wrapper.write_text(original)
|
||||
r = _run_script(mem, str(remote), wrapper=wrapper)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
backup = wrapper.with_suffix(".js.orig")
|
||||
assert backup.exists(), ".orig backup not created"
|
||||
assert backup.read_text() == original, ".orig does not preserve original content"
|
||||
assert wrapper.read_text() != original, "wrapper not replaced with delegate"
|
||||
# Second run with different wrong content: .orig must NOT be overwritten.
|
||||
wrapper.write_text("# another wrong\n")
|
||||
r2 = _run_script(mem, str(remote), wrapper=wrapper)
|
||||
assert r2.returncode == 0, f"run2: stdout={r2.stdout}\nstderr={r2.stderr}"
|
||||
assert backup.read_text() == original, ".orig overwritten on 2nd backup"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
Loading…
Add table
Reference in a new issue