diff --git a/scripts/cutover.py b/scripts/cutover.py index 3fe8d5d..7aaf54f 100644 --- a/scripts/cutover.py +++ b/scripts/cutover.py @@ -62,14 +62,21 @@ class Config: return self.bridge / "sdlc-hub" +def _git_env() -> dict: + return dict(os.environ, GIT_OPTIONAL_LOCKS="0", GIT_TERMINAL_PROMPT="0") + + def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: - env = dict(os.environ, GIT_OPTIONAL_LOCKS="0") - return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env=env) + return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env=_git_env(), + stdin=subprocess.DEVNULL) def preflight(cfg: Config) -> List[str]: """Проверки шага 1. Только чтение.""" problems: List[str] = [] + for tool in ("uv", "make", "claude", "launchctl", "node"): + if not shutil.which(tool): + problems.append("не найден инструмент %s в PATH (нужен для шагов переключения)" % tool) old = cfg.old_checkout if not old.is_dir(): problems.append("старый checkout %s не найден" % old) @@ -90,7 +97,8 @@ def preflight(cfg: Config) -> List[str]: problems.append("не удалось получить origin/main из %s" % old) elif remote_head != head: problems.append("HEAD не совпадает с origin/main: сделайте git pull --ff-only в %s" % old) - lr = subprocess.run(["git", "ls-remote", cfg.remote], capture_output=True, text=True) + lr = subprocess.run(["git", "ls-remote", cfg.remote], capture_output=True, text=True, env=_git_env(), + stdin=subprocess.DEVNULL) if lr.returncode != 0: problems.append("репозиторий %s недоступен: переименуйте aep/jira-hub -> aep/sdlc-hub в Gitea (%s)" % (cfg.remote, lr.stderr.strip().splitlines()[-1] if lr.stderr.strip() else "нет ответа")) @@ -188,6 +196,14 @@ def _run(*cmd: str, cwd: Path = None, check: bool = True) -> subprocess.Complete return subprocess.run(list(cmd), cwd=str(cwd) if cwd else None, check=check) +def _port_open(port: int) -> bool: + try: + socket.create_connection(("127.0.0.1", port), timeout=1).close() + return True + except OSError: + return False + + def _domain() -> str: return "gui/%d" % os.getuid() @@ -217,6 +233,8 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) _run("launchctl", "bootout", "%s/%s" % (_domain(), OLD_LABEL), check=False) # не загружен - не ошибка if old_plist.exists(): old_plist.unlink() + if _port_open(cfg.port): + raise RuntimeError("после остановки %s порт %d всё ещё занят: хаб не остановлен" % (OLD_LABEL, cfg.port)) def move_data() -> None: shutil.move(str(cfg.data_root / "jira-hub"), str(cfg.data_root / "sdlc-hub")) @@ -237,12 +255,11 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) _run("launchctl", "bootstrap", _domain(), str(new_plist)) deadline = time.time() + 30 while time.time() < deadline: - try: - socket.create_connection(("127.0.0.1", cfg.port), timeout=1).close() + if _port_open(cfg.port): return - except OSError: - time.sleep(1) - raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s)" % (cfg.port, log)) + time.sleep(1) + raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s); остановить: launchctl bootout %s/%s" + % (cfg.port, log, _domain(), NEW_LABEL)) def consumers() -> None: for project in cfg.projects: @@ -264,9 +281,12 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) leftovers.extend("%s: %s" % (f, ln) for ln in rewrite_text(f)) def user_scope() -> None: - _run("claude", "mcp", "remove", "-s", "user", "jira-hub") _run("claude", "mcp", "add", "-s", "user", "sdlc-jira", "--", "uv", "run", "--project", str(new_co), "sdlc-hub", "mcp", "jira") + r = _run("claude", "mcp", "remove", "-s", "user", "jira-hub", check=False) + if r.returncode != 0: + print(" ПРЕДУПРЕЖДЕНИЕ: claude mcp remove -s user jira-hub не выполнился (код %d): удалите запись руками" + % r.returncode) return [ ("2. остановить launchd %s и удалить %s" % (OLD_LABEL, old_plist), stop), @@ -276,7 +296,7 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) ("5. поставить и запустить %s (лог %s), дождаться порта %d" % (NEW_LABEL, log, cfg.port), start), ("6. править проекты %s (.mcp.json, .claude/settings*.json, CLAUDE.md, .claude/skills; копии *%s)" % (", ".join(str(p) for p in cfg.projects), SUFFIX), consumers), - ("7. user scope Claude: remove jira-hub, add sdlc-jira", user_scope), + ("7. user scope Claude: add sdlc-jira, затем remove jira-hub", user_scope), ] @@ -303,17 +323,50 @@ def main(argv: List[str]) -> int: if problems: print("Остановлено: исправьте проблемы шага 1, ничего не изменено.") return 1 + done: List[str] = [] for desc, fn in plan: print("-> " + desc) try: fn() except Exception as e: # noqa: BLE001 print("остановлено на шаге %s: %s" % (desc.split(".")[0], e)) + print("Выполнено: " + ("; ".join(done) if done else "ничего")) + print("Промежуточное состояние (по выполненным шагам; упавший шаг мог выполниться частично, проверьте):") + for line in _state_lines(done): + print(" " + line) + print("Откат: остановить ru.aepif.sdlc-hub (launchctl bootout %s/%s), перенести каталоги обратно " + "(данные %s -> %s, checkout %s -> %s, origin вернуть на aep/jira-hub), вернуть plist %s и " + "launchctl bootstrap %s; раздел «Переключение с jira-hub» в README." % ( + _domain(), NEW_LABEL, cfg.data_root / "sdlc-hub", cfg.data_root / "jira-hub", + cfg.new_checkout, cfg.old_checkout, cfg.launch_agents / (OLD_LABEL + ".plist"), _domain())) + _print_tail(backups, leftovers) return 1 + done.append(desc) print(" готово") print("\nОсталось владельцу:") print(" - /mcp reconnect в открытых сессиях Claude") print(" - codegraph init в %s и в каждом worktree (.wt/*)" % cfg.new_checkout) + _print_tail(backups, leftovers) + return 0 + + +def _state_lines(done: List[str]) -> List[str]: + n = {d.split(".")[0] for d in done} + out = [] + if "2" in n: + out.append("хаб ru.aepif.jira-hub остановлен, его plist удалён") + if "3" in n: + out.append("каталог данных перенесён в sdlc-hub") + if "4" in n: + out.append("checkout перенесён в sdlc-hub, origin переключён") + if "5" in n: + out.append("ru.aepif.sdlc-hub установлен и запущен") + if "6" in n: + out.append("файлы проектов изменены (копии *%s)" % SUFFIX) + return out or ["ничего не изменено"] + + +def _print_tail(backups: List[Path], leftovers: List[str]) -> None: print(" - проверить правки и удалить копии *%s:" % SUFFIX) for b in backups: print(" " + str(b)) @@ -321,7 +374,6 @@ def main(argv: List[str]) -> int: print(" - проверить руками (остались упоминания jira-hub):") for ln in leftovers: print(" " + ln) - return 0 if __name__ == "__main__": diff --git a/tests/test_cutover.py b/tests/test_cutover.py index 7762332..659101c 100644 --- a/tests/test_cutover.py +++ b/tests/test_cutover.py @@ -60,6 +60,7 @@ def env(tmp_path, monkeypatch): "CUTOVER_LOGS": tmp_path / "logs", "CUTOVER_REMOTE": bare, "CUTOVER_PROJECTS": proj, }.items(): monkeypatch.setenv(k, str(v)) + monkeypatch.setattr(cutover.shutil, "which", lambda name: "/usr/bin/" + name) return {"tmp": tmp_path, "bridge": bridge, "old": old, "data": data, "bare": bare, "proj": proj} @@ -148,3 +149,60 @@ def test_dirty_checkout_refused(env): def test_preflight_clean(env): assert cutover.preflight(cutover.Config.from_env()) == [] + + +def test_preflight_reports_missing_tool(env, monkeypatch): + monkeypatch.setattr(cutover.shutil, "which", lambda name: None if name in ("claude", "make") else "/x/" + name) + probs = cutover.preflight(cutover.Config.from_env()) + assert any("claude" in s for s in probs) and any("make" in s for s in probs) + assert not any("uv " in s for s in probs) + + +class _Fake: + def __init__(self, rc_by_cmd=None): + self.calls = [] + self.rc_by_cmd = rc_by_cmd or {} + + def __call__(self, cmd, **kw): + self.calls.append((list(cmd), kw)) + rc = self.rc_by_cmd.get(cmd[2] if len(cmd) > 2 else "", 0) + r = subprocess.CompletedProcess(cmd, rc) + if kw.get("check") and rc: + raise subprocess.CalledProcessError(rc, cmd) + return r + + +def test_step7_add_before_remove_and_remove_not_fatal(env, monkeypatch, capsys): + fake = _Fake({"remove": 1}) + monkeypatch.setattr(cutover.subprocess, "run", fake) + cfg = cutover.Config.from_env() + desc, fn = cutover.steps(cfg)[-1] + fn() + cmds = [c for c, _ in fake.calls] + assert cmds[0][:3] == ["claude", "mcp", "add"] and "sdlc-jira" in cmds[0] + assert cmds[1][:3] == ["claude", "mcp", "remove"] + assert fake.calls[1][1].get("check") is False + assert "ПРЕДУПРЕЖДЕНИЕ" in capsys.readouterr().out + + +def test_step2_fails_if_port_still_open(env, monkeypatch): + monkeypatch.setattr(cutover.subprocess, "run", _Fake()) + monkeypatch.setattr(cutover, "_port_open", lambda port: True) + cfg = cutover.Config.from_env() + with pytest.raises(RuntimeError, match="порт"): + cutover.steps(cfg)[0][1]() + + +def test_failure_prints_state_and_rollback(env, monkeypatch, capsys): + def boom(): + raise RuntimeError("сломалось") + + plan = [("2. a", lambda: None), ("3. b", lambda: None), ("4. c", boom)] + monkeypatch.setattr(cutover, "preflight", lambda cfg: []) + monkeypatch.setattr(cutover, "steps", lambda cfg, leftovers=None, backups=None: plan) + assert cutover.main(["--apply"]) == 1 + out = capsys.readouterr().out + assert "остановлено на шаге 4: сломалось" in out + assert "2. a" in out and "3. b" in out + assert "хаб ru.aepif.jira-hub остановлен" in out and "каталог данных перенесён" in out + assert "Откат" in out and "README" in out