From 08db1da9d5121813c39076fbf1106fbb7d374c7b Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 20 Sep 2026 20:43:20 +0900 Subject: [PATCH] fix(issue-21): PR #29 review R1-R3 - untracked-safe cleanliness checks; a worktree gets its own venv R1/R2: `git status --porcelain` honours status.showUntrackedFiles, so a `no` setting in a submodule (or the worktree) hid untracked files from the checks that justify worktree_remove.sh's single --force and from the review snapshot guard (_dirty_submodules), which then approved content it never captured. All three checks (lib_py/worktree.py, worktree_remove.sh, runner._dirty_submodules) now pass --untracked-files=all. The source tree's own snapshot was already safe: `git add -A` ignores that setting. R3: worktree_prepare.sh no longer links .venv to the control root's. It creates the worktree's own venv with the control root's interpreter and installs requirements*.txt; a leftover link is refused (exit 1), a control root without an interpreter is a warning (VENV=skipped); VENV= is printed. Tests: w1 (real venv, kept on re-run), w1b (link refused / skipped), w6e parametrised over the hidden setting for the post-stop recheck, w6e2 (hidden untracked file in the submodule and in the worktree blocks the removal), l17 untracked-hidden / during-hidden (guard refuses before the first review; a mid-round hidden file makes the round stale). Each new case fails without the fix. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .agents/skills/lib_py/loop/runner.py | 7 +-- .agents/skills/lib_py/worktree.py | 7 ++- .../skills/multi-agent-mux-create/SKILL.md | 2 +- .../scripts/worktree_prepare.sh | 36 +++++++++---- .agents/skills/multi-agent-mux-stop/SKILL.md | 2 +- .../scripts/worktree_remove.sh | 6 ++- docs/design/worktree-creator-model.md | 4 +- tests/test_issue21_loop_b.py | 19 +++++-- tests/test_issue21_phase2_loop_a.py | 53 +++++++++++++++++-- 9 files changed, 107 insertions(+), 29 deletions(-) diff --git a/.agents/skills/lib_py/loop/runner.py b/.agents/skills/lib_py/loop/runner.py index 94bc0f3..b864b56 100644 --- a/.agents/skills/lib_py/loop/runner.py +++ b/.agents/skills/lib_py/loop/runner.py @@ -712,9 +712,10 @@ class LoopRunner: def _dirty_submodules(self) -> Optional[List[str]]: """Checked-out submodules (recursively) with uncommitted or untracked content; None when the check itself fails. Each submodule is asked - directly with --ignore-submodules=none, so no submodule.*.ignore or - diff.ignoreSubmodules setting can hide it.""" - script = ('s=$(git status --porcelain --ignore-submodules=none) || exit 1; ' + directly with --ignore-submodules=none and --untracked-files=all, so no + submodule.*.ignore, diff.ignoreSubmodules or status.showUntrackedFiles + setting can hide it.""" + script = ('s=$(git status --porcelain --untracked-files=all --ignore-submodules=none) || exit 1; ' '[ -z "$s" ] || printf "%s\\n" "$displaypath"') r = self._git(self.worktree_root, "submodule", "foreach", "--recursive", "--quiet", script) if r.returncode != 0: diff --git a/.agents/skills/lib_py/worktree.py b/.agents/skills/lib_py/worktree.py index b6ee9c1..8493dad 100644 --- a/.agents/skills/lib_py/worktree.py +++ b/.agents/skills/lib_py/worktree.py @@ -155,7 +155,10 @@ def submodule_findings(wt: str) -> Dict[str, object]: after `submodule update`, so its commits may be on no branch, and a commit kept only by a tag (review R6) or a note is lost with the repository just the same. Stashes live in refs/stash and its reflog; the stash check owns - them.""" + them. --untracked-files=all: `status.showUntrackedFiles=no` in a + submodule would otherwise hide its untracked files from the check (review + R1), and display settings must not decide what a forced removal may + delete.""" gd = _git(wt, "rev-parse", "--absolute-git-dir") if gd.returncode != 0 or not gd.stdout.strip(): return {"trace": False, "dirty": "", "unpushed": "", "stashes": "", @@ -164,7 +167,7 @@ def submodule_findings(wt: str) -> Dict[str, object]: "dirty": "", "unpushed": "", "stashes": "", "error": ""} if not res["trace"]: return res - checks = (("dirty", "git status --porcelain"), + checks = (("dirty", "git status --porcelain --untracked-files=all"), ("unpushed", "git log --oneline " + " ".join(UNPUSHED_REVS)), ("stashes", "git stash list")) errors = [] diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 372aff9..08fa0fa 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -243,7 +243,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \ [--creator-agent claude [--session ] [--role creator] [--herdr-session ] [--herdr-workspace ] [--onboard]] [--dry-run] ``` -- 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). +- Creates the worktree next to the control root (a path **inside** it is refused), idempotently; creates the worktree's **own** `.venv` from the control root's `.venv/bin/python` and installs `requirements.txt` / `requirements-dev.txt` when present (never a link - a leftover link is refused with exit 1; no control-root interpreter is a warning and `VENV=skipped`); 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` 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`. 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 b0c86c9..99f426a 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh @@ -8,8 +8,8 @@ # [--creator-agent [--session ] [--role ] # [--herdr-session ] [--herdr-workspace ] [--onboard]] [--dry-run] # -# Steps: git worktree (idempotent) -> .venv link to the control root's -> -# /.venv and .pytest_cache/ in the shared info/exclude -> submodules only with +# Steps: git worktree (idempotent) -> the worktree's own .venv (control root +# interpreter + requirements*.txt) -> /.venv and .pytest_cache/ in the shared info/exclude -> submodules only with # --submodules (a failed init is rolled back: it would otherwise leave a # modules/ trace that makes a plain `git worktree remove` refuse) -> optional # creator session registered in the control root's .mam -> summary with BASE=. @@ -79,7 +79,7 @@ fi if [ "$DRY_RUN" = "1" ]; then echo "[dry-run] control root: $MAIN" echo "[dry-run] worktree: $WT (branch $BRANCH${BASE_REF:+, base $BASE_REF})" - echo "[dry-run] .venv link and shared info/exclude entries; submodules: $([ "$SUBMODULES" = 1 ] && echo init || echo skipped)" + echo "[dry-run] own .venv (from $MAIN/.venv/bin/python + requirements*.txt) and shared info/exclude entries; submodules: $([ "$SUBMODULES" = 1 ] && echo init || echo skipped)" [ -n "$CREATOR_AGENT" ] && echo "[dry-run] creator: $CREATOR_AGENT as $SESSION (role $ROLE)" exit 0 fi @@ -108,13 +108,30 @@ else fi fi -# 2. .venv: a link to the control root's, never a copy. -if [ -e "$MAIN/.venv" ]; then - if [ ! -e "$WT/.venv" ] && [ ! -L "$WT/.venv" ]; then - ln -s "$MAIN/.venv" "$WT/.venv" - elif [ "$(readlink "$WT/.venv" 2>/dev/null || true)" != "$MAIN/.venv" ]; then - echo "WARNING: '$WT/.venv' exists and is not a link to '$MAIN/.venv'; left as it is" >&2 +# 2. .venv: the worktree's own environment, created with the control root's +# interpreter and its requirements files, never a link (review R3: a link +# aliases the control root's environment, and a `git add -A` in the +# worktree once committed the link itself - see .gitignore's managed +# block). Idempotent: an existing usable venv is kept. +VENV_STATE="skipped" +if [ -L "$WT/.venv" ]; then + echo "ERROR: '$WT/.venv' is a symbolic link; a worktree needs its own venv - remove the link and re-run" >&2 + exit 1 +elif [ -x "$WT/.venv/bin/python" ]; then + VENV_STATE="kept" +elif [ -x "$MAIN/.venv/bin/python" ]; then + "$MAIN/.venv/bin/python" -m venv "$WT/.venv" || { echo "ERROR: could not create '$WT/.venv'" >&2; exit 1; } + req=() + for f in requirements.txt requirements-dev.txt; do + [ -f "$WT/$f" ] && req+=(-r "$WT/$f") + done + if [ "${#req[@]}" -gt 0 ]; then + "$WT/.venv/bin/python" -m pip install -q "${req[@]}" \ + || { echo "ERROR: pip install into '$WT/.venv' failed; fix it and re-run (the worktree is kept)" >&2; exit 1; } fi + VENV_STATE="created" +else + echo "WARNING: '$MAIN/.venv/bin/python' not found; no venv created in the worktree" >&2 fi # 3. Ignore rules. A linked worktree's info/exclude is the shared @@ -228,6 +245,7 @@ echo "WORKTREE=$WT" echo "BRANCH=$BRANCH" echo "BASE=$BASE_SHA" echo "SUBMODULES=$SUB_STATE" +echo "VENV=$VENV_STATE" [ -n "$CREATOR_AGENT" ] && echo "SESSION=$SESSION" echo "next: bash $SKILL_DIR/multi-agent-mux-loop/scripts/run_loop.sh --worktree '$WT' --base $BASE_SHA --review-only --creator --reviewer --task '...'" echo "then: bash $SKILL_DIR/multi-agent-mux-stop/scripts/worktree_remove.sh --path '$WT' --stop" diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index 87ea10f..8d49505 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -120,7 +120,7 @@ Every check runs first and changes nothing; a refusal exits 3: 1. a linked worktree of the control root, not locked (`git worktree unlock` first - git refuses even `--force` on a locked one); 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`); +4. a clean tree, submodules included (`status --porcelain --untracked-files=all --ignore-submodules=none` - a `status.showUntrackedFiles=no` setting cannot hide a file from a check that decides a forced removal); 5. inside submodules: no modification, no commit reachable from `HEAD` or any local ref that is on no remote (a submodule sits on a detached HEAD; a commit kept only by a tag or a note is protected too) and no stash (`refs/stash` is reachable from no branch and leaves the status clean) - checked in every repository under the worktree's `modules/`, including deinitialised submodules (their repository stays there) and nested ones; a repository that cannot be checked refuses. 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. 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 4537197..997f98d 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh @@ -84,11 +84,13 @@ 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. +# METHOD from what it saw and returns 1 on any finding. --untracked-files=all: +# a status.showUntrackedFiles=no setting (worktree or submodule) must not hide +# files from a check that decides a forced removal (review R1). METHOD="plain" # set before any early return below: the summary line reads it under set -u judge_tree() { local found=0 dirty sub trace key val - if ! dirty="$(git -C "$WT" status --porcelain --ignore-submodules=none 2>&1)"; then + if ! dirty="$(git -C "$WT" status --porcelain --untracked-files=all --ignore-submodules=none 2>&1)"; then refuse "git status failed in the worktree: $dirty" if [ ! -d "$WT" ]; then echo " the directory no longer exists; if that is intended, run: git -C '$MAIN' worktree prune" >&2 diff --git a/docs/design/worktree-creator-model.md b/docs/design/worktree-creator-model.md index 64285f5..313ed43 100644 --- a/docs/design/worktree-creator-model.md +++ b/docs/design/worktree-creator-model.md @@ -88,9 +88,9 @@ bash "$MAIN/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" --pa - **1단계 운영 형태 = 구현 병렬, 리뷰 루프 하나**: creator 구현은 루프 밖(delegate-job direct)에서 워크트리마다 병렬로 한다. 리뷰는 `run_loop.sh --worktree --base <구현 전 sha> --review-only` 로 한 번에 하나씩 한다. base 를 구현 위임 **전에** 기록하지 않으면 리뷰를 시작할 수 없다(가드가 빈 변경분을 거부한다). busy 처리는 큐가 아니다(1회 재시도). 다중 루프는 항목 8(리뷰어 스케줄러) 이후다. - 준비·해체 헬퍼는 항목 4 로 구현했다(`worktree_prepare.sh`, `worktree_remove.sh`). - `.mam.env` 는 복사하지 않는다. creator 에는 제어 루트의 것을 `MAM_ENV_FILE` 로 준다. - - `.venv` 는 제어 루트 것의 심링크다. 무시 규칙(`/.venv`, `.pytest_cache/`)은 linked worktree 가 공유하는 `$GIT_COMMON_DIR/info/exclude` 에 들어간다. 그래서 본 체크아웃에도 적용되며, 둘 다 본 체크아웃에서도 무시해야 하는 산출물이다. + - `.venv` 는 워크트리 자체의 것이다: 제어 루트의 `.venv/bin/python` 으로 만들고 `requirements*.txt` 를 설치한다. 심링크는 만들지 않으며 남아 있으면 prepare 가 거부한다(링크는 제어 루트 환경을 그대로 가리키고, 워크트리의 `git add -A` 가 링크 자체를 커밋한 적이 있다). 무시 규칙(`/.venv`, `.pytest_cache/`)은 linked worktree 가 공유하는 `$GIT_COMMON_DIR/info/exclude` 에 들어간다. 그래서 본 체크아웃에도 적용되며, 둘 다 본 체크아웃에서도 무시해야 하는 산출물이다. - 서브모듈은 `--submodules` 로만 초기화한다. 초기화는 성공·실패와 무관하게 워크트리 git 디렉터리에 `modules/` 를 남기고, 그러면 git 이 force 없는 `worktree remove` 를 거부한다. 초기화하지 않은 서브모듈 경로에 이미 파일이 있으면 아무것도 바꾸지 않고 exit 3 으로 거부한다. prepare 는 실패한 시도가 만든 것만 되돌리고 exit 1 한다. 새 체크아웃은 git 디렉터리보다 먼저 deinit 해서 워크트리가 쓸 수 있는 체크아웃으로 남게 하고, 새 `submodule.*` 공유 설정 키는 지운다. 이전 초기화 상태와 기존 설정 값은 보존한다. - - 서브모듈을 초기화한 워크트리는 remove 가 수정, HEAD 와 모든 로컬 ref(tag·notes 포함)에서 도달하는, 원격에 없는 커밋(서브모듈은 detached HEAD 다), stash 를 확인한 뒤 `--force` 를 한 번만 써서 제거한다. 이 확인은 초기화된 체크아웃뿐 아니라 워크트리 `modules/` 아래의 모든 저장소(deinit 된 서브모듈, 중첩 서브모듈 포함)에 직접 하며, 확인할 수 없는 저장소가 있으면 거부한다. 이 확인은 `--stop` 으로 세션을 정지한 뒤 제거 직전에 한 번 더 한다(정지 중에도 쓰기·커밋·stash 가 생길 수 있다). **사람이 `git worktree remove --force` 를 직접 쓰지 말 것.** 워크트리 전용 서브모듈 저장소와 미푸시 커밋이 함께 사라진다. + - 서브모듈을 초기화한 워크트리는 remove 가 수정(untracked 포함, `--untracked-files=all` 이라 `status.showUntrackedFiles` 설정으로 숨길 수 없다), HEAD 와 모든 로컬 ref(tag·notes 포함)에서 도달하는, 원격에 없는 커밋(서브모듈은 detached HEAD 다), stash 를 확인한 뒤 `--force` 를 한 번만 써서 제거한다. 이 확인은 초기화된 체크아웃뿐 아니라 워크트리 `modules/` 아래의 모든 저장소(deinit 된 서브모듈, 중첩 서브모듈 포함)에 직접 하며, 확인할 수 없는 저장소가 있으면 거부한다. 이 확인은 `--stop` 으로 세션을 정지한 뒤 제거 직전에 한 번 더 한다(정지 중에도 쓰기·커밋·stash 가 생길 수 있다). **사람이 `git worktree remove --force` 를 직접 쓰지 말 것.** 워크트리 전용 서브모듈 저장소와 미푸시 커밋이 함께 사라진다. ## 5. 필요한 변경 (Issue #21 작업 항목) diff --git a/tests/test_issue21_loop_b.py b/tests/test_issue21_loop_b.py index ff657a6..1e426c2 100644 --- a/tests/test_issue21_loop_b.py +++ b/tests/test_issue21_loop_b.py @@ -417,28 +417,37 @@ def _add_submodule(wt: Path, tmp_path: Path) -> None: _git(wt, "commit", "-q", "-m", "add sm") -@pytest.mark.parametrize("when", ["before", "before-ignored", "untracked", "during"]) +@pytest.mark.parametrize("when", ["before", "before-ignored", "untracked", "untracked-hidden", "during", "during-hidden"]) def test_l17_dirty_submodule_is_refused_or_makes_the_round_stale(wt_repo, monkeypatch, tmp_path, capsys, when): + """The -hidden variants (PR #29 review R2): status.showUntrackedFiles=no in + the submodule must not let an untracked file past the snapshot guard, + neither before the first review nor when it appears mid-round.""" main, wt = wt_repo base = _impl(wt) _add_submodule(wt, tmp_path) if when == "before-ignored": # no ignore setting hides the dirty content _git(wt, "config", "submodule.sm.ignore", "all") _git(wt, "config", "diff.ignoreSubmodules", "all") + if when.endswith("-hidden"): + _git(wt / "sm", "config", "status.showUntrackedFiles", "no") def dirty(*_): (wt / "sm" / "x.txt").write_text("unreviewed work\n") + def untracked(*_): + (wt / "sm" / "new.txt").write_text("untracked in the submodule\n") + if when.startswith("before"): dirty() - elif when == "untracked": - (wt / "sm" / "new.txt").write_text("untracked in the submodule\n") + elif when.startswith("untracked"): + untracked() + on_review = {"during": dirty, "during-hidden": untracked}.get(when) rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], - on_review=dirty if when == "during" else None) + on_review=on_review) assert rc == 1 and _sidecars(main) == [] err = capsys.readouterr().err assert "submodule(s) with uncommitted or untracked content: sm" in err - if when == "during": + if when.startswith("during"): assert len(_reviews(calls)) == 1 and r._rounds[0]["stale"] is True else: assert _reviews(calls) == [], "refused before any review" diff --git a/tests/test_issue21_phase2_loop_a.py b/tests/test_issue21_phase2_loop_a.py index 6b274c7..05428f0 100644 --- a/tests/test_issue21_phase2_loop_a.py +++ b/tests/test_issue21_phase2_loop_a.py @@ -254,13 +254,20 @@ def test_w9_already_running_resume_persists_the_binding(mam_sandbox, mock_herdr, # --------------------------------------------------------------------------- def test_w1_prepare_worktree_venv_exclude_base(ctl_sub): + """R3 (PR #29 review): the worktree gets its own venv from the control + root's interpreter, never a link to the control root's .venv (a link + aliases the environment and was once committed by `git add -A`).""" main = ctl_sub wt = _wt(main) - for _ in range(2): + shutil.rmtree(main / ".venv") + subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(main / ".venv")], check=True) + for expect in ("VENV=created", "VENV=kept"): res = _prepare(main, wt) assert res.returncode == 0, res.stdout + res.stderr + assert expect in res.stdout assert os.path.realpath(wt) in _worktrees(main) - assert os.readlink(wt / ".venv") == str(main / ".venv") + assert not os.path.islink(wt / ".venv") and os.access(wt / ".venv" / "bin" / "python", os.X_OK) + assert os.path.realpath(wt / ".venv") != os.path.realpath(main / ".venv") excl = (main / ".git" / "info" / "exclude").read_text().splitlines() assert excl.count("/.venv") == 1 and excl.count(".pytest_cache/") == 1 assert _git(wt, "status", "--porcelain").stdout == "" @@ -271,6 +278,18 @@ def test_w1_prepare_worktree_venv_exclude_base(ctl_sub): assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0 +def test_w1b_prepare_refuses_a_venv_link_and_skips_without_a_control_root_interpreter(ctl): + main = ctl # ctl's .venv is an empty directory: no interpreter to create from + wt = _wt(main) + res = _prepare(main, wt) + assert res.returncode == 0 and "VENV=skipped" in res.stdout and "no venv created" in res.stderr + assert not (wt / ".venv").exists() + os.symlink(main / ".venv", wt / ".venv") + res = _prepare(main, wt) + assert res.returncode == 1 and "symbolic link" in res.stderr, res.stdout + res.stderr + assert os.path.islink(wt / ".venv"), "nothing is changed by the refusal" + + def test_w2_prepare_refusals_and_dry_run(ctl): main = ctl res = _run(main, PREP, "--path", str(main / "inside"), "--branch", "x") @@ -439,12 +458,16 @@ def test_w6d_failed_init_trace_outside_prepare_is_removable(ctl_sub): # must leave a usable checkout # --------------------------------------------------------------------------- -def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herdr, mock_agents): +@pytest.mark.parametrize("hidden", [False, True]) +def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herdr, mock_agents, hidden): """R1: the checks run again after --stop; a file a stopping session wrote - into a submodule keeps the worktree (and the file).""" + into a submodule keeps the worktree (and the file) - also when the + submodule's status.showUntrackedFiles=no would hide it (PR #29 review R1).""" main = ctl_sub wt = _wt(main) assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + if hidden: + _git(wt / "sm", "config", "status.showUntrackedFiles", "no") _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")) @@ -455,6 +478,28 @@ def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herd assert late.is_file() and os.path.realpath(wt) in _worktrees(main) +def test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal(ctl_sub): + """PR #29 review R1: `status --porcelain` honours status.showUntrackedFiles; + a display setting must not decide what a forced removal deletes.""" + main = ctl_sub + wt = _wt(main) + assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0 + _git(wt / "sm", "config", "status.showUntrackedFiles", "no") + keep = wt / "sm" / "valuable-untracked.txt" + keep.write_text("not committed anywhere\n") + assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "", "git hides it by default" + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "'dirty'" in res.stderr, res.stdout + res.stderr + assert keep.is_file() and os.path.realpath(wt) in _worktrees(main) + _git(wt, "config", "status.showUntrackedFiles", "no") # the worktree's own status check too + keep.unlink() + top = wt / "valuable-top.txt" + top.write_text("x\n") + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 3 and "uncommitted changes" in res.stderr, res.stdout + res.stderr + assert top.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."""