From bbbbb95ef6acfeb6baa285f041ccb71dd7710199 Mon Sep 17 00:00:00 2001 From: Godopu Date: Mon, 14 Sep 2026 16:57:39 +0000 Subject: [PATCH] fix(issue-21): review ffb5055e R1-R3 - re-check the tree after --stop, refuse submodule stashes, roll a partial submodule init back to a usable checkout Review ffb5055e (planner-reviewer-codex-01): - R1 [P1] worktree_remove.sh: the clean-tree and submodule checks ran only before --stop, so work a stopping session wrote (or committed, or stashed) was deleted by the forced removal. The checks are one function now, run before any change and again right before the removal; any finding then refuses with exit 3 (sessions already stopped, worktree kept), and the removal method follows that latest look. - R2 [P1] lib_py/worktree.py: a submodule stash is reachable from no branch and leaves the status clean, and the forced removal deletes the worktree's own submodule repositories. `git stash list` in every submodule is a separate finding (`stashes`), refused like dirty/unpushed; errors fail closed as before. - R3 [P2] worktree_prepare.sh: when one submodule cloned and a later one failed, the rollback deleted modules/ while the cloned checkout's .git file still pointed into it, and `git status` failed in the worktree. The rollback now undoes only this attempt: each submodule whose git dir did not exist before is deinitialised before its git dir is deleted; submodules initialised earlier are kept; shared submodule.* config values are restored exactly (new keys removed, pre-existing values put back - init and deinit both write .git/config). A final `git status` either confirms a usable checkout or prints what to inspect. Tests W6e (a write during stop keeps the worktree and the file), W6f (a submodule stash refuses), W6g (partial failure on a fresh worktree: usable, no modules/, no new config, then prepare and a plain removal work) and W6h (a failing retry keeps the earlier `sm` init and its config value). The skill docs and the design doc describe the recheck, the stash rule and the scoped rollback. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G --- .agents/skills/lib_py/worktree.py | 18 ++-- .../skills/multi-agent-mux-create/SKILL.md | 2 +- .../scripts/worktree_prepare.sh | 46 ++++++++-- .agents/skills/multi-agent-mux-stop/SKILL.md | 4 +- .../scripts/worktree_remove.sh | 64 +++++++++----- docs/design/worktree-creator-model.md | 4 +- tests/test_issue21_phase2_loop_a.py | 85 +++++++++++++++++++ 7 files changed, 181 insertions(+), 42 deletions(-) diff --git a/.agents/skills/lib_py/worktree.py b/.agents/skills/lib_py/worktree.py index d935e73..100a5c3 100644 --- a/.agents/skills/lib_py/worktree.py +++ b/.agents/skills/lib_py/worktree.py @@ -124,19 +124,23 @@ def open_jobs(rows: List[dict], dirs: Iterable[str], exclude_ids: Iterable[str] def submodule_findings(wt: str) -> Dict[str, object]: """trace: the worktree git dir holds modules/ (git then refuses a plain - `worktree remove`, even after a failed init); dirty / unpushed: what a - forced removal would destroy. `HEAD` is part of the unpushed check: a - submodule is on a detached HEAD after `submodule update`, so commits made - there are on no branch and `--branches --not --remotes` alone misses them.""" + `worktree remove`, even after a failed init); dirty / unpushed / stashes: + what a forced removal would destroy - it deletes the worktree's own + submodule repositories. `HEAD` is part of the unpushed check: a submodule + is on a detached HEAD after `submodule update`, so commits made there are + on no branch and `--branches --not --remotes` alone misses them. Stashes + live in refs/stash (and its reflog), reachable from neither.""" gd = _git(wt, "rev-parse", "--absolute-git-dir") if gd.returncode != 0 or not gd.stdout.strip(): - return {"trace": False, "dirty": "", "unpushed": "", "error": gd.stderr.strip() or "rev-parse failed"} + return {"trace": False, "dirty": "", "unpushed": "", "stashes": "", + "error": gd.stderr.strip() or "rev-parse failed"} res: Dict[str, object] = {"trace": os.path.isdir(os.path.join(gd.stdout.strip(), "modules")), - "dirty": "", "unpushed": "", "error": ""} + "dirty": "", "unpushed": "", "stashes": "", "error": ""} if not res["trace"]: return res checks = (("dirty", "git status --porcelain"), - ("unpushed", "git log --oneline HEAD --branches --not --remotes")) + ("unpushed", "git log --oneline HEAD --branches --not --remotes"), + ("stashes", "git stash list")) errors = [] for key, cmd in checks: r = _git(wt, "submodule", "foreach", "--recursive", "--quiet", cmd) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 37ffe5e..7427a5d 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -244,7 +244,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \ ``` - Creates the worktree next to the control root (a path **inside** it is refused), idempotently; links `.venv` to the control root's; adds `/.venv` and `.pytest_cache/` to the shared `info/exclude` (a linked worktree's exclude file is `$GIT_COMMON_DIR/info/exclude`, so it applies to the main checkout too). -- **Submodules are opt-in** (`--submodules`). Initialisation writes `submodule..url` to the shared `.git/config`, and any attempt - even a failed one - leaves a `modules/` directory that makes a plain `git worktree remove` refuse. A failed `--submodules` removes those traces and exits 1; re-run without `--submodules` to finish. +- **Submodules are opt-in** (`--submodules`). Initialisation writes `submodule..url` to the shared `.git/config`, and any attempt - even a failed one - leaves a `modules/` directory that makes a plain `git worktree remove` refuse. A failed `--submodules` undoes only what that attempt created - new submodule checkouts are deinitialised before their git dirs are deleted, so the worktree stays a usable checkout; submodules initialised earlier and pre-existing `submodule.*` values are kept - and exits 1; re-run without `--submodules` to finish. - `--creator-agent` creates the creator through this skill with the control root's binding and `MAM_NO_SUBSCRIBER_DAEMON=1`; the row's `pane.cwd` is the worktree. - Prints `BASE=`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree --base "$BASE" --review-only`. - Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule. diff --git a/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh index 105fa82..c70106e 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh @@ -130,21 +130,51 @@ if [ -f "$WT/.gitmodules" ]; then if [ "$SUBMODULES" = "1" ]; then gd="$(git -C "$WT" rev-parse --absolute-git-dir)" had_modules=0 - [ -e "$gd/modules" ] && had_modules=1 - new_keys=() - while read -r key _; do + if [ -e "$gd/modules" ]; then had_modules=1; fi + # The state before this attempt, so a failure undoes only what it created + # (review R3): per submodule its path and whether its git dir already + # existed (an earlier init - kept), and the exact shared config values + # (init and deinit both write the common .git/config). Indexed arrays only + # (bash 3.2 has no associative ones). + sm_names=(); sm_paths=(); sm_pre=(); cfg_keys=(); cfg_had=(); cfg_vals=() + while read -r key path; do [ -n "$key" ] || continue name="${key#submodule.}"; name="${name%.path}" + sm_names+=("$name"); sm_paths+=("$path") + if [ -e "$gd/modules/$name" ]; then sm_pre+=(1); else sm_pre+=(0); fi for k in url active; do - git -C "$MAIN" config --get "submodule.$name.$k" >/dev/null 2>&1 || new_keys+=("submodule.$name.$k") + cfg_keys+=("submodule.$name.$k") + if v="$(git -C "$MAIN" config --get "submodule.$name.$k" 2>/dev/null)"; then + cfg_had+=(1); cfg_vals+=("$v") + else + cfg_had+=(0); cfg_vals+=("") + fi done done < <(git config --file "$WT/.gitmodules" --get-regexp '^submodule\..*\.path$' 2>/dev/null || true) if ! git -C "$WT" submodule update --init --recursive; then - [ "$had_modules" = "1" ] || rm -rf "$gd/modules" - for k in ${new_keys[@]+"${new_keys[@]}"}; do - git -C "$MAIN" config --unset-all "$k" 2>/dev/null || true + # A submodule cloned by this attempt leaves a checkout whose .git file + # points into modules/: deinit it before its git dir goes, or the + # worktree stops being a usable git checkout. + for i in $(seq 0 $(( ${#sm_names[@]} - 1 ))); do + if [ "${sm_pre[$i]}" = "1" ]; then continue; fi + git -C "$WT" submodule deinit -q -f -- "${sm_paths[$i]}" >/dev/null 2>&1 || true + if [ -f "$WT/${sm_paths[$i]}/.git" ]; then rm -f "$WT/${sm_paths[$i]}/.git"; fi + rm -rf "$gd/modules/${sm_names[$i]}" done - echo "ERROR: submodule initialisation failed; its traces (modules/, new submodule.* config keys) were removed so the worktree can still be removed without --force. Re-run without --submodules to finish preparing." >&2 + if [ "$had_modules" != "1" ]; then rm -rf "$gd/modules"; fi + for i in $(seq 0 $(( ${#cfg_keys[@]} - 1 ))); do + if [ "${cfg_had[$i]}" = "1" ]; then + git -C "$MAIN" config "${cfg_keys[$i]}" "${cfg_vals[$i]}" + else + git -C "$MAIN" config --unset-all "${cfg_keys[$i]}" 2>/dev/null || true + fi + done + if st_err="$(git -C "$WT" status --porcelain 2>&1 >/dev/null)"; then + echo "ERROR: submodule initialisation failed; this attempt's traces (new submodule checkouts, their git dirs, new submodule.* config keys) were removed and earlier submodule state was kept, so the worktree stays usable and removable. Re-run without --submodules to finish preparing." >&2 + else + echo "ERROR: submodule initialisation failed, and '$WT' is not a usable git checkout after the rollback: $st_err" >&2 + echo " inspect it with: git -C '$WT' submodule status - do not force-remove it before checking its submodules" >&2 + fi exit 1 fi SUB_STATE="initialised" diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index e1a923c..9e98ef0 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -121,9 +121,9 @@ Every check runs first and changes nothing; a refusal exits 3: 2. running sessions under it only with `--stop` (stopped normally, conversations kept); 3. no open job of those sessions - never cancelled automatically, they may belong to a running loop (the delegate job of a session `--stop` stops is cancelled by stop itself); 4. a clean tree, submodules included (`status --porcelain --ignore-submodules=none`); -5. inside submodules: no modification and no commit that is on no remote (`HEAD` included - a submodule sits on a detached HEAD). +5. inside submodules: no modification, no commit that is on no remote (`HEAD` included - a submodule sits on a detached HEAD) and no stash (`refs/stash` is reachable from no branch and leaves the status clean). -Then it stops the sessions, re-checks the jobs and removes the worktree (the branch is kept): plain, or - when the worktree holds submodule state, which git refuses to remove otherwise - a **single `--force` justified by checks 4 and 5**. Do not run `git worktree remove --force` by hand on such a worktree: it deletes the worktree's own submodule repositories, unpushed commits included. +Then it stops the sessions, re-checks the jobs, runs checks 4 and 5 **again** (a session that is being stopped can still write, commit or stash - any change refuses with exit 3, sessions already stopped) and removes the worktree (the branch is kept): plain, or - when the worktree holds submodule state, which git refuses to remove otherwise - a **single `--force` justified by checks 4 and 5**. Do not run `git worktree remove --force` by hand on such a worktree: it deletes the worktree's own submodule repositories, unpushed commits included. Exit codes: 0 removed, 1 stop/git failure, 2 not a worktree of the control root, 3 refused (nothing changed). Sessions created in a removed worktree cannot be resumed until a worktree exists at that path again. diff --git a/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh b/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh index d1c255d..e2adf65 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh @@ -11,7 +11,9 @@ # delegate job of a session --stop will stop is cancelled by stop itself); # (4) clean tree, submodules included; (5) submodules: nothing modified and no # commit that is on no remote (HEAD included - a submodule is on a detached -# HEAD). Removal is plain, or - when the worktree holds submodule state, which +# HEAD) and no stash. (4) and (5) run again right before the removal, after +# --stop: a session that is being stopped can still write, commit or stash. +# Removal is plain, or - when the worktree holds submodule state, which # git refuses to remove without it - a single --force justified by (4) and (5). # Never run `git worktree remove --force` by hand on such a worktree. # @@ -79,27 +81,37 @@ if [ "$(printf '%s' "$JOBS" | jget 'len(d)')" != "0" ]; then echo " close one after checking it: python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py --registry-dir close --job --request cancelled" >&2 fi -# (4) clean, submodules included -DIRTY="$(git -C "$WT" status --porcelain --ignore-submodules=none)" -if [ -n "$DIRTY" ]; then - refuse "the worktree has uncommitted changes:" - printf '%s\n' "$DIRTY" | sed 's/^/ /' >&2 -fi - -# (5) submodules -SUB="$(wt_py submodules "$WT")" -TRACE="$(printf '%s' "$SUB" | jget 'd["trace"]')" -METHOD="plain" -if [ "$TRACE" = "True" ]; then - METHOD="single --force" - for key in error dirty unpushed; do - val="$(printf '%s' "$SUB" | jget "d['$key']")" - if [ -n "$val" ]; then - refuse "submodule check '$key' is not empty (a forced removal would destroy it):" - printf '%s\n' "$val" | sed 's/^/ /' >&2 - fi - done -fi +# (4) clean, submodules included, and (5) submodules. A function: it runs now, +# before any change, and again right before the removal (review R1). It sets +# METHOD from what it saw and returns 1 on any finding. +judge_tree() { + local found=0 dirty sub trace key val + if ! dirty="$(git -C "$WT" status --porcelain --ignore-submodules=none 2>&1)"; then + refuse "git status failed in the worktree: $dirty" + return 1 + fi + if [ -n "$dirty" ]; then + refuse "the worktree has uncommitted changes:" + printf '%s\n' "$dirty" | sed 's/^/ /' >&2 + found=1 + fi + sub="$(wt_py submodules "$WT")" + trace="$(printf '%s' "$sub" | jget 'd["trace"]')" + METHOD="plain" + if [ "$trace" = "True" ]; then + METHOD="single --force" + for key in error dirty unpushed stashes; do + val="$(printf '%s' "$sub" | jget "d.get('$key', '')")" + if [ -n "$val" ]; then + refuse "submodule check '$key' is not empty (a forced removal would destroy it):" + printf '%s\n' "$val" | sed 's/^/ /' >&2 + found=1 + fi + done + fi + return "$found" +} +judge_tree || true echo "checks: locked/sessions/jobs/clean/submodules -> $([ "$REFUSED" = 1 ] && echo refused || echo passed); removal method: $METHOD" if [ "$REFUSED" = "1" ]; then @@ -130,6 +142,14 @@ if [ "$STOP" = "1" ] && [ -n "$RUNNING_AGENTS" ]; then fi fi +# (6b) the tree again, right before removing it: the stopped sessions may have +# written, committed or stashed while they stopped (review R1). The +# removal method follows this latest look. +if ! judge_tree; then + echo "REFUSED: ${RUNNING:+the sessions were stopped, but }the worktree changed after the checks; it was not removed" >&2 + exit 3 +fi + # (7) remove; the branch is kept if [ "$METHOD" = "plain" ]; then git -C "$MAIN" worktree remove "$WT" || exit 1 diff --git a/docs/design/worktree-creator-model.md b/docs/design/worktree-creator-model.md index 7a7078b..9c54eb9 100644 --- a/docs/design/worktree-creator-model.md +++ b/docs/design/worktree-creator-model.md @@ -89,8 +89,8 @@ bash "$MAIN/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" --pa - 준비·해체 헬퍼는 항목 4 로 구현했다(`worktree_prepare.sh`, `worktree_remove.sh`). - `.mam.env` 는 복사하지 않는다. creator 에는 제어 루트의 것을 `MAM_ENV_FILE` 로 준다. - `.venv` 는 제어 루트 것의 심링크다. 무시 규칙(`/.venv`, `.pytest_cache/`)은 linked worktree 가 공유하는 `$GIT_COMMON_DIR/info/exclude` 에 들어간다. 그래서 본 체크아웃에도 적용되며, 둘 다 본 체크아웃에서도 무시해야 하는 산출물이다. - - 서브모듈은 `--submodules` 로만 초기화한다. 초기화는 성공·실패와 무관하게 워크트리 git 디렉터리에 `modules/` 를 남기고, 그러면 git 이 force 없는 `worktree remove` 를 거부한다. prepare 는 실패한 초기화의 흔적(`modules/`, 새 `submodule.*` 공유 설정)을 되돌리고 exit 1 한다. - - 서브모듈을 초기화한 워크트리는 remove 가 수정과 원격에 없는 커밋(`HEAD` 포함 — 서브모듈은 detached HEAD 다)을 확인한 뒤 `--force` 를 한 번만 써서 제거한다. **사람이 `git worktree remove --force` 를 직접 쓰지 말 것.** 워크트리 전용 서브모듈 저장소와 미푸시 커밋이 함께 사라진다. + - 서브모듈은 `--submodules` 로만 초기화한다. 초기화는 성공·실패와 무관하게 워크트리 git 디렉터리에 `modules/` 를 남기고, 그러면 git 이 force 없는 `worktree remove` 를 거부한다. prepare 는 실패한 시도가 만든 것만 되돌리고 exit 1 한다. 새 체크아웃은 git 디렉터리보다 먼저 deinit 해서 워크트리가 쓸 수 있는 체크아웃으로 남게 하고, 새 `submodule.*` 공유 설정 키는 지운다. 이전 초기화 상태와 기존 설정 값은 보존한다. + - 서브모듈을 초기화한 워크트리는 remove 가 수정, 원격에 없는 커밋(`HEAD` 포함 — 서브모듈은 detached HEAD 다), stash 를 확인한 뒤 `--force` 를 한 번만 써서 제거한다. 이 확인은 `--stop` 으로 세션을 정지한 뒤 제거 직전에 한 번 더 한다(정지 중에도 쓰기·커밋·stash 가 생길 수 있다). **사람이 `git worktree remove --force` 를 직접 쓰지 말 것.** 워크트리 전용 서브모듈 저장소와 미푸시 커밋이 함께 사라진다. ## 5. 필요한 변경 (Issue #21 작업 항목) diff --git a/tests/test_issue21_phase2_loop_a.py b/tests/test_issue21_phase2_loop_a.py index 9dbce76..c7cd7e0 100644 --- a/tests/test_issue21_phase2_loop_a.py +++ b/tests/test_issue21_phase2_loop_a.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import os +import shlex import shutil import subprocess import sys @@ -431,3 +432,87 @@ def test_w6d_failed_init_trace_outside_prepare_is_removable(ctl_sub): assert os.path.isdir(os.path.join(gd, "modules")), "git leaves modules/ behind after a failed init" res = _run(main, REM, "--path", str(wt)) assert res.returncode == 0 and "single --force" in res.stdout, res.stdout + res.stderr + + +# --------------------------------------------------------------------------- +# review ffb5055e: work that a forced removal must not destroy; rollback that +# must leave a usable checkout +# --------------------------------------------------------------------------- + +def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herdr, mock_agents): + """R1: the checks run again after --stop; a file a stopping session wrote + into a submodule keeps the worktree (and the file).""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + _mutate(main, f"d['herdr_sessions'] = [{_row(main, 'late-creator-claude', wt)}]") + late = wt / "sm" / "late-user-work.txt" + stop = Path(_skill(main, "multi-agent-mux-stop", "scripts", "stop_session.sh")) + stop.write_text("#!/usr/bin/env bash\nprintf 'unsaved work\\n' > " + shlex.quote(str(late)) + "\n" + stop.read_text()) + res = _run(main, REM, "--path", str(wt), "--stop") + assert res.returncode == 3, res.stdout + res.stderr + assert "changed after the checks" in res.stderr + assert late.is_file() and os.path.realpath(wt) in _worktrees(main) + + +def test_w6f_submodule_stash_blocks_the_forced_removal(ctl_sub): + """R2: a stash in a submodule is reachable from no branch and leaves the + status clean; it must still refuse the forced removal.""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + (wt / "sm" / "s.txt").write_text("valuable stashed work\n") + _git(wt / "sm", "stash", "push", "-q", "-m", "keep this work") + assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "" + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "'stashes'" in res.stderr, res.stdout + res.stderr + assert os.path.realpath(wt) in _worktrees(main) + assert _git(wt / "sm", "rev-parse", "--verify", "-q", "refs/stash", check=False).returncode == 0 + + +def _add_unreachable_submodule(repo: Path, missing: Path) -> None: + """A second submodule `zbad` (sorted after `sm`) whose remote does not exist.""" + sm_sha = _git(repo, "ls-tree", "HEAD", "sm").stdout.split()[2] + _git(repo, "config", "-f", ".gitmodules", "submodule.zbad.path", "zbad") + _git(repo, "config", "-f", ".gitmodules", "submodule.zbad.url", str(missing)) + _git(repo, "add", ".gitmodules") + _git(repo, "update-index", "--add", "--cacheinfo", f"160000,{sm_sha},zbad") + _git(repo, "commit", "-q", "-m", "second, unreachable submodule") + + +def test_w6g_partial_submodule_failure_rolls_back_to_a_usable_worktree(ctl_sub): + """R3: `sm` clones, `zbad` fails. The rollback deinits the new checkout + before deleting its git dir, so the worktree stays a usable checkout.""" + main = ctl_sub + _add_unreachable_submodule(main, main.parent / "missing-remote.git") + wt = _wt(main) + res = _prepare(main, wt, "--submodules", env=FILE_OK) + assert res.returncode == 1 and "submodule initialisation failed" in res.stderr + assert _git(wt, "status", "--porcelain", check=False).returncode == 0 + gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() + assert not os.path.exists(os.path.join(gd, "modules")) + assert not (wt / "sm" / ".git").exists() + for name in ("sm", "zbad"): + assert _git(main, "config", "--get", f"submodule.{name}.url", check=False).returncode != 0 + assert _prepare(main, wt).returncode == 0 + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr + + +def test_w6h_failed_retry_keeps_the_earlier_submodule_state(ctl_sub): + """R3: a submodule initialised by an earlier run (and its shared config) + survives the rollback of a later, failing run.""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + url_before = _git(main, "config", "--get", "submodule.sm.url").stdout.strip() + _add_unreachable_submodule(wt, main.parent / "missing-remote.git") + res = _prepare(main, wt, "--submodules", env=FILE_OK) + assert res.returncode == 1, res.stdout + res.stderr + assert _git(wt, "status", "--porcelain", check=False).returncode == 0 + gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() + assert os.path.isdir(os.path.join(gd, "modules", "sm")) + assert not os.path.exists(os.path.join(gd, "modules", "zbad")) + assert (wt / "sm" / ".git").exists() and (wt / "sm" / "s.txt").exists() + assert _git(main, "config", "--get", "submodule.sm.url").stdout.strip() == url_before + assert _git(main, "config", "--get", "submodule.zbad.url", check=False).returncode != 0