fix(sdlc-rename): cutover: откат при сбое, проверка инструментов, порядок add/remove

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 12:09:16 +04:00
parent 7ac525699a
commit 40cb66158f
2 changed files with 121 additions and 11 deletions

View File

@ -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__":

View File

@ -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