Compare commits
3
Commits
46ea4cc1e4
...
08db1da9d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08db1da9d5 | ||
|
|
90a289da44 | ||
|
|
7a94ba2c3e |
No files matched your search
@@ -47,7 +47,7 @@ def conversation_in_use(uuid: str, paths=()) -> bool:
|
|||||||
if os.environ.get(var, "").strip() == uuid:
|
if os.environ.get(var, "").strip() == uuid:
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="],
|
out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="],
|
||||||
capture_output=True, text=True, timeout=10).stdout
|
capture_output=True, text=True, timeout=10).stdout
|
||||||
except Exception:
|
except Exception:
|
||||||
out = ""
|
out = ""
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ def _is_agent_cli(command: str) -> bool:
|
|||||||
def _process_table():
|
def _process_table():
|
||||||
"""{pid: (ppid, command)} from ps; empty when ps is unavailable."""
|
"""{pid: (ppid, command)} from ps; empty when ps is unavailable."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True).stdout
|
out = subprocess.run(["ps", "-eo", "pid=,ppid=,args="], capture_output=True, text=True).stdout
|
||||||
except OSError:
|
except OSError:
|
||||||
return {}
|
return {}
|
||||||
table = {}
|
table = {}
|
||||||
|
|||||||
@@ -712,9 +712,10 @@ class LoopRunner:
|
|||||||
def _dirty_submodules(self) -> Optional[List[str]]:
|
def _dirty_submodules(self) -> Optional[List[str]]:
|
||||||
"""Checked-out submodules (recursively) with uncommitted or untracked
|
"""Checked-out submodules (recursively) with uncommitted or untracked
|
||||||
content; None when the check itself fails. Each submodule is asked
|
content; None when the check itself fails. Each submodule is asked
|
||||||
directly with --ignore-submodules=none, so no submodule.*.ignore or
|
directly with --ignore-submodules=none and --untracked-files=all, so no
|
||||||
diff.ignoreSubmodules setting can hide it."""
|
submodule.*.ignore, diff.ignoreSubmodules or status.showUntrackedFiles
|
||||||
script = ('s=$(git status --porcelain --ignore-submodules=none) || exit 1; '
|
setting can hide it."""
|
||||||
|
script = ('s=$(git status --porcelain --untracked-files=all --ignore-submodules=none) || exit 1; '
|
||||||
'[ -z "$s" ] || printf "%s\\n" "$displaypath"')
|
'[ -z "$s" ] || printf "%s\\n" "$displaypath"')
|
||||||
r = self._git(self.worktree_root, "submodule", "foreach", "--recursive", "--quiet", script)
|
r = self._git(self.worktree_root, "submodule", "foreach", "--recursive", "--quiet", script)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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")
|
gd = _git(wt, "rev-parse", "--absolute-git-dir")
|
||||||
if gd.returncode != 0 or not gd.stdout.strip():
|
if gd.returncode != 0 or not gd.stdout.strip():
|
||||||
return {"trace": False, "dirty": "", "unpushed": "", "stashes": "",
|
return {"trace": False, "dirty": "", "unpushed": "", "stashes": "",
|
||||||
@@ -164,7 +167,7 @@ def submodule_findings(wt: str) -> Dict[str, object]:
|
|||||||
"dirty": "", "unpushed": "", "stashes": "", "error": ""}
|
"dirty": "", "unpushed": "", "stashes": "", "error": ""}
|
||||||
if not res["trace"]:
|
if not res["trace"]:
|
||||||
return res
|
return res
|
||||||
checks = (("dirty", "git status --porcelain"),
|
checks = (("dirty", "git status --porcelain --untracked-files=all"),
|
||||||
("unpushed", "git log --oneline " + " ".join(UNPUSHED_REVS)),
|
("unpushed", "git log --oneline " + " ".join(UNPUSHED_REVS)),
|
||||||
("stashes", "git stash list"))
|
("stashes", "git stash list"))
|
||||||
errors = []
|
errors = []
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \
|
|||||||
[--creator-agent claude [--session <name>] [--role creator] [--herdr-session <s>] [--herdr-workspace <w>] [--onboard]] [--dry-run]
|
[--creator-agent claude [--session <name>] [--role creator] [--herdr-session <s>] [--herdr-workspace <w>] [--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.<name>.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.
|
- **Submodules are opt-in** (`--submodules`). Initialisation writes `submodule.<name>.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.
|
- `--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=<sha>`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree <WT> --base "$BASE" --review-only`.
|
- Prints `BASE=<sha>`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree <WT> --base "$BASE" --review-only`.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
# [--creator-agent <agent> [--session <name>] [--role <role>]
|
# [--creator-agent <agent> [--session <name>] [--role <role>]
|
||||||
# [--herdr-session <s>] [--herdr-workspace <w>] [--onboard]] [--dry-run]
|
# [--herdr-session <s>] [--herdr-workspace <w>] [--onboard]] [--dry-run]
|
||||||
#
|
#
|
||||||
# Steps: git worktree (idempotent) -> .venv link to the control root's ->
|
# Steps: git worktree (idempotent) -> the worktree's own .venv (control root
|
||||||
# /.venv and .pytest_cache/ in the shared info/exclude -> submodules only with
|
# 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
|
# --submodules (a failed init is rolled back: it would otherwise leave a
|
||||||
# modules/ trace that makes a plain `git worktree remove` refuse) -> optional
|
# modules/ trace that makes a plain `git worktree remove` refuse) -> optional
|
||||||
# creator session registered in the control root's .mam -> summary with BASE=.
|
# creator session registered in the control root's .mam -> summary with BASE=.
|
||||||
@@ -79,7 +79,7 @@ fi
|
|||||||
if [ "$DRY_RUN" = "1" ]; then
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
echo "[dry-run] control root: $MAIN"
|
echo "[dry-run] control root: $MAIN"
|
||||||
echo "[dry-run] worktree: $WT (branch $BRANCH${BASE_REF:+, base $BASE_REF})"
|
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)"
|
[ -n "$CREATOR_AGENT" ] && echo "[dry-run] creator: $CREATOR_AGENT as $SESSION (role $ROLE)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -108,13 +108,30 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. .venv: a link to the control root's, never a copy.
|
# 2. .venv: the worktree's own environment, created with the control root's
|
||||||
if [ -e "$MAIN/.venv" ]; then
|
# interpreter and its requirements files, never a link (review R3: a link
|
||||||
if [ ! -e "$WT/.venv" ] && [ ! -L "$WT/.venv" ]; then
|
# aliases the control root's environment, and a `git add -A` in the
|
||||||
ln -s "$MAIN/.venv" "$WT/.venv"
|
# worktree once committed the link itself - see .gitignore's managed
|
||||||
elif [ "$(readlink "$WT/.venv" 2>/dev/null || true)" != "$MAIN/.venv" ]; then
|
# block). Idempotent: an existing usable venv is kept.
|
||||||
echo "WARNING: '$WT/.venv' exists and is not a link to '$MAIN/.venv'; left as it is" >&2
|
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
|
fi
|
||||||
|
VENV_STATE="created"
|
||||||
|
else
|
||||||
|
echo "WARNING: '$MAIN/.venv/bin/python' not found; no venv created in the worktree" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Ignore rules. A linked worktree's info/exclude is the shared
|
# 3. Ignore rules. A linked worktree's info/exclude is the shared
|
||||||
@@ -228,6 +245,7 @@ echo "WORKTREE=$WT"
|
|||||||
echo "BRANCH=$BRANCH"
|
echo "BRANCH=$BRANCH"
|
||||||
echo "BASE=$BASE_SHA"
|
echo "BASE=$BASE_SHA"
|
||||||
echo "SUBMODULES=$SUB_STATE"
|
echo "SUBMODULES=$SUB_STATE"
|
||||||
|
echo "VENV=$VENV_STATE"
|
||||||
[ -n "$CREATOR_AGENT" ] && echo "SESSION=$SESSION"
|
[ -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 <creator> --reviewer <reviewers> --task '...'"
|
echo "next: bash $SKILL_DIR/multi-agent-mux-loop/scripts/run_loop.sh --worktree '$WT' --base $BASE_SHA --review-only --creator <creator> --reviewer <reviewers> --task '...'"
|
||||||
echo "then: bash $SKILL_DIR/multi-agent-mux-stop/scripts/worktree_remove.sh --path '$WT' --stop"
|
echo "then: bash $SKILL_DIR/multi-agent-mux-stop/scripts/worktree_remove.sh --path '$WT' --stop"
|
||||||
@@ -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);
|
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);
|
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);
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ fi
|
|||||||
|
|
||||||
# (4) clean, submodules included, and (5) submodules. A function: it runs now,
|
# (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
|
# 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
|
METHOD="plain" # set before any early return below: the summary line reads it under set -u
|
||||||
judge_tree() {
|
judge_tree() {
|
||||||
local found=0 dirty sub trace key val
|
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"
|
refuse "git status failed in the worktree: $dirty"
|
||||||
if [ ! -d "$WT" ]; then
|
if [ ! -d "$WT" ]; then
|
||||||
echo " the directory no longer exists; if that is intended, run: git -C '$MAIN' worktree prune" >&2
|
echo " the directory no longer exists; if that is intended, run: git -C '$MAIN' worktree prune" >&2
|
||||||
|
|||||||
@@ -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 <WT> --base <구현 전 sha> --review-only` 로 한 번에 하나씩 한다. base 를 구현 위임 **전에** 기록하지 않으면 리뷰를 시작할 수 없다(가드가 빈 변경분을 거부한다). busy 처리는 큐가 아니다(1회 재시도). 다중 루프는 항목 8(리뷰어 스케줄러) 이후다.
|
- **1단계 운영 형태 = 구현 병렬, 리뷰 루프 하나**: creator 구현은 루프 밖(delegate-job direct)에서 워크트리마다 병렬로 한다. 리뷰는 `run_loop.sh --worktree <WT> --base <구현 전 sha> --review-only` 로 한 번에 하나씩 한다. base 를 구현 위임 **전에** 기록하지 않으면 리뷰를 시작할 수 없다(가드가 빈 변경분을 거부한다). busy 처리는 큐가 아니다(1회 재시도). 다중 루프는 항목 8(리뷰어 스케줄러) 이후다.
|
||||||
- 준비·해체 헬퍼는 항목 4 로 구현했다(`worktree_prepare.sh`, `worktree_remove.sh`).
|
- 준비·해체 헬퍼는 항목 4 로 구현했다(`worktree_prepare.sh`, `worktree_remove.sh`).
|
||||||
- `.mam.env` 는 복사하지 않는다. creator 에는 제어 루트의 것을 `MAM_ENV_FILE` 로 준다.
|
- `.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.*` 공유 설정 키는 지운다. 이전 초기화 상태와 기존 설정 값은 보존한다.
|
- 서브모듈은 `--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 작업 항목)
|
## 5. 필요한 변경 (Issue #21 작업 항목)
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ def _isolate_mam_state_dirs(_ambient_control_plane, tmp_path, monkeypatch):
|
|||||||
state = tmp_path / ".mam-test-state"
|
state = tmp_path / ".mam-test-state"
|
||||||
monkeypatch.setenv("MAM_JOBS_DIR", str(state / "jobs"))
|
monkeypatch.setenv("MAM_JOBS_DIR", str(state / "jobs"))
|
||||||
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(state / "delegate_job_logs"))
|
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(state / "delegate_job_logs"))
|
||||||
|
# Issue #28: a workspace whose real .mam.env names a reachable broker made
|
||||||
|
# "broker unavailable" tests (T1-4) read it through the wrapper's env loader.
|
||||||
|
# Point every test at an absent env file; a test that wants a real broker
|
||||||
|
# config sets MAM_ENV_FILE itself.
|
||||||
|
monkeypatch.setenv("MAM_ENV_FILE", str(state / ".mam.env.absent"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -279,12 +279,19 @@ def _resolve_private_server_doc() -> str:
|
|||||||
|
|
||||||
|
|
||||||
PRIVATE_SERVER_DOC_PATH = _resolve_private_server_doc()
|
PRIVATE_SERVER_DOC_PATH = _resolve_private_server_doc()
|
||||||
|
# Issue #28: these guards read the nats-docker submodule. A checkout without
|
||||||
|
# `git submodule update --init` has nothing to check; skip with the reason
|
||||||
|
# instead of failing on every host that cloned without submodules.
|
||||||
|
needs_private_server_doc = pytest.mark.skipif(
|
||||||
|
not os.path.exists(PRIVATE_SERVER_DOC_PATH),
|
||||||
|
reason="nats-docker submodule not initialized (git submodule update --init)")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment
|
# D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment
|
||||||
# variables that broker_config_from_env() actually parses.
|
# variables that broker_config_from_env() actually parses.
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d11_private_server_env_names_valid():
|
def test_d11_private_server_env_names_valid():
|
||||||
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
|
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
|
||||||
import mqtt_common
|
import mqtt_common
|
||||||
@@ -315,6 +322,7 @@ def test_d11_private_server_env_names_valid():
|
|||||||
# D-12 — (G-D2) PRIVATE_SERVER.md must not contain invalid MAM_MQTT_* in
|
# D-12 — (G-D2) PRIVATE_SERVER.md must not contain invalid MAM_MQTT_* in
|
||||||
# active configuration code blocks.
|
# active configuration code blocks.
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d12_private_server_no_mam_mqtt_in_code_fences():
|
def test_d12_private_server_no_mam_mqtt_in_code_fences():
|
||||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||||
with open(doc_path, "r", encoding="utf-8") as f:
|
with open(doc_path, "r", encoding="utf-8") as f:
|
||||||
@@ -331,6 +339,7 @@ def test_d12_private_server_no_mam_mqtt_in_code_fences():
|
|||||||
# D-13 — (G-D3) nats-server launch instructions in PRIVATE_SERVER.md must
|
# D-13 — (G-D3) nats-server launch instructions in PRIVATE_SERVER.md must
|
||||||
# use valid config blocks (mqtt {) and not HTTP port flag (-m 1883).
|
# use valid config blocks (mqtt {) and not HTTP port flag (-m 1883).
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d13_private_server_nats_config_valid():
|
def test_d13_private_server_nats_config_valid():
|
||||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||||
with open(doc_path, "r", encoding="utf-8") as f:
|
with open(doc_path, "r", encoding="utf-8") as f:
|
||||||
@@ -347,6 +356,7 @@ def test_d13_private_server_nats_config_valid():
|
|||||||
# D-14 — (G-D4) Verification commands in PRIVATE_SERVER.md must use valid
|
# D-14 — (G-D4) Verification commands in PRIVATE_SERVER.md must use valid
|
||||||
# CLI flags matching the actual scripts' argparse parsers.
|
# CLI flags matching the actual scripts' argparse parsers.
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d14_private_server_cli_args_valid():
|
def test_d14_private_server_cli_args_valid():
|
||||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||||
with open(doc_path, "r", encoding="utf-8") as f:
|
with open(doc_path, "r", encoding="utf-8") as f:
|
||||||
@@ -367,6 +377,7 @@ def test_d14_private_server_cli_args_valid():
|
|||||||
# D-15 — (G-D5) store_dir in code blocks must be absolute path (/ or $HOME)
|
# D-15 — (G-D5) store_dir in code blocks must be absolute path (/ or $HOME)
|
||||||
# and heredocs writing it must be unquoted (<<EOF).
|
# and heredocs writing it must be unquoted (<<EOF).
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d15_private_server_store_dir_valid():
|
def test_d15_private_server_store_dir_valid():
|
||||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||||
with open(doc_path, "r", encoding="utf-8") as f:
|
with open(doc_path, "r", encoding="utf-8") as f:
|
||||||
@@ -388,6 +399,7 @@ def test_d15_private_server_store_dir_valid():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-16 — (G-D6) nats image references in code fences must use pinned alpine
|
# D-16 — (G-D6) nats image references in code fences must use pinned alpine
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d16_private_server_nats_image_alpine_pinned():
|
def test_d16_private_server_nats_image_alpine_pinned():
|
||||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||||
with open(doc_path, "r", encoding="utf-8") as f:
|
with open(doc_path, "r", encoding="utf-8") as f:
|
||||||
@@ -404,6 +416,7 @@ def test_d16_private_server_nats_image_alpine_pinned():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1
|
# D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d17_private_server_monitoring_port_localhost_bound():
|
def test_d17_private_server_monitoring_port_localhost_bound():
|
||||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||||
with open(doc_path, "r", encoding="utf-8") as f:
|
with open(doc_path, "r", encoding="utf-8") as f:
|
||||||
@@ -421,6 +434,7 @@ def test_d17_private_server_monitoring_port_localhost_bound():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER
|
# D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d18_private_server_tls_examples_use_domain_names():
|
def test_d18_private_server_tls_examples_use_domain_names():
|
||||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||||
with open(doc_path, "r", encoding="utf-8") as f:
|
with open(doc_path, "r", encoding="utf-8") as f:
|
||||||
@@ -437,6 +451,7 @@ def test_d18_private_server_tls_examples_use_domain_names():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT
|
# D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_private_server_doc
|
||||||
def test_d19_private_server_subject_literals_match_default_topic_root():
|
def test_d19_private_server_subject_literals_match_default_topic_root():
|
||||||
doc_path = PRIVATE_SERVER_DOC_PATH
|
doc_path = PRIVATE_SERVER_DOC_PATH
|
||||||
with open(doc_path, "r", encoding="utf-8") as f:
|
with open(doc_path, "r", encoding="utf-8") as f:
|
||||||
@@ -492,6 +507,9 @@ def _resolve_docker_dir() -> str:
|
|||||||
|
|
||||||
DOCKER_DIR = _resolve_docker_dir()
|
DOCKER_DIR = _resolve_docker_dir()
|
||||||
COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml")
|
COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml")
|
||||||
|
needs_docker_assets = pytest.mark.skipif(
|
||||||
|
not os.path.exists(COMPOSE_PATH),
|
||||||
|
reason="nats-docker submodule not initialized (git submodule update --init)")
|
||||||
NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf")
|
NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf")
|
||||||
ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example")
|
ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example")
|
||||||
DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md")
|
DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md")
|
||||||
@@ -523,6 +541,7 @@ def _active_conf_lines():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-22 — docker/ assets exist and are populated
|
# D-22 — docker/ assets exist and are populated
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d22_docker_assets_exist_and_are_populated():
|
def test_d22_docker_assets_exist_and_are_populated():
|
||||||
for p in [COMPOSE_PATH, NATS_CONF_PATH, ENV_EXAMPLE_PATH, DOCKER_README_PATH]:
|
for p in [COMPOSE_PATH, NATS_CONF_PATH, ENV_EXAMPLE_PATH, DOCKER_README_PATH]:
|
||||||
assert os.path.exists(p), f"Required asset {p} does not exist"
|
assert os.path.exists(p), f"Required asset {p} does not exist"
|
||||||
@@ -533,6 +552,7 @@ def test_d22_docker_assets_exist_and_are_populated():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-23 — compose image matches doc and is alpine
|
# D-23 — compose image matches doc and is alpine
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d23_compose_image_matches_doc_and_is_alpine():
|
def test_d23_compose_image_matches_doc_and_is_alpine():
|
||||||
_, svc = _load_compose()
|
_, svc = _load_compose()
|
||||||
compose_img = svc.get("image", "")
|
compose_img = svc.get("image", "")
|
||||||
@@ -552,6 +572,7 @@ def test_d23_compose_image_matches_doc_and_is_alpine():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-24 — compose port exposure contract
|
# D-24 — compose port exposure contract
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d24_compose_port_exposure_contract():
|
def test_d24_compose_port_exposure_contract():
|
||||||
_, svc = _load_compose()
|
_, svc = _load_compose()
|
||||||
ports = svc.get("ports", [])
|
ports = svc.get("ports", [])
|
||||||
@@ -573,6 +594,7 @@ def test_d24_compose_port_exposure_contract():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-25 — secrets are fail closed
|
# D-25 — secrets are fail closed
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d25_secrets_are_fail_closed():
|
def test_d25_secrets_are_fail_closed():
|
||||||
_, svc = _load_compose()
|
_, svc = _load_compose()
|
||||||
env = svc.get("environment", {})
|
env = svc.get("environment", {})
|
||||||
@@ -615,6 +637,7 @@ def test_d25_secrets_are_fail_closed():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-26 — nats_conf jetstream and mqtt contract
|
# D-26 — nats_conf jetstream and mqtt contract
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d26_nats_conf_jetstream_and_mqtt_contract():
|
def test_d26_nats_conf_jetstream_and_mqtt_contract():
|
||||||
_, svc = _load_compose()
|
_, svc = _load_compose()
|
||||||
volumes = svc.get("volumes", [])
|
volumes = svc.get("volumes", [])
|
||||||
@@ -645,6 +668,7 @@ def test_d26_nats_conf_jetstream_and_mqtt_contract():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-27 — observer permissions match topic root
|
# D-27 — observer permissions match topic root
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d27_observer_permissions_match_topic_root():
|
def test_d27_observer_permissions_match_topic_root():
|
||||||
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
|
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
|
||||||
import mqtt_common
|
import mqtt_common
|
||||||
@@ -666,6 +690,7 @@ def test_d27_observer_permissions_match_topic_root():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-28 — healthcheck contract and image coupling
|
# D-28 — healthcheck contract and image coupling
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d28_healthcheck_contract_and_image_coupling():
|
def test_d28_healthcheck_contract_and_image_coupling():
|
||||||
_, svc = _load_compose()
|
_, svc = _load_compose()
|
||||||
hc = svc.get("healthcheck")
|
hc = svc.get("healthcheck")
|
||||||
@@ -684,6 +709,7 @@ def test_d28_healthcheck_contract_and_image_coupling():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-29 — env secrets never tracked
|
# D-29 — env secrets never tracked
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d29_env_secrets_never_tracked():
|
def test_d29_env_secrets_never_tracked():
|
||||||
is_submodule = os.path.exists(os.path.join(REPO_ROOT, ".gitmodules")) and "nats-docker" in DOCKER_DIR
|
is_submodule = os.path.exists(os.path.join(REPO_ROOT, ".gitmodules")) and "nats-docker" in DOCKER_DIR
|
||||||
target_repo = os.path.join(REPO_ROOT, "nats-docker") if is_submodule else REPO_ROOT
|
target_repo = os.path.join(REPO_ROOT, "nats-docker") if is_submodule else REPO_ROOT
|
||||||
@@ -703,6 +729,7 @@ def test_d29_env_secrets_never_tracked():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# D-30 — websocket origin policy is startable
|
# D-30 — websocket origin policy is startable
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
@needs_docker_assets
|
||||||
def test_d30_websocket_origin_policy_is_startable():
|
def test_d30_websocket_origin_policy_is_startable():
|
||||||
active_lines = _active_conf_lines()
|
active_lines = _active_conf_lines()
|
||||||
active_text = "\n".join(active_lines)
|
active_text = "\n".join(active_lines)
|
||||||
|
|||||||
@@ -510,7 +510,10 @@ def test_wrapper_idle_grace_never_outlives_the_wall_clock(tmp_path):
|
|||||||
import calendar
|
import calendar
|
||||||
closed_at = calendar.timegm(time.strptime(job_data["updated_at"], "%Y-%m-%dT%H:%M:%SZ"))
|
closed_at = calendar.timegm(time.strptime(job_data["updated_at"], "%Y-%m-%dT%H:%M:%SZ"))
|
||||||
close_delay = closed_at - started
|
close_delay = closed_at - started
|
||||||
assert close_delay <= timeout_s + 8, f"idle grace outlived --timeout {timeout_s}s: closed after {close_delay:.0f}s\n{stderr}"
|
# Under full-suite load the wrapper's final poll, close and registry write
|
||||||
|
# have landed 8-9 s past the deadline (three full runs); the defect this
|
||||||
|
# pins was a close at ~2 x TIMEOUT, so a 15 s margin still separates them.
|
||||||
|
assert close_delay <= timeout_s + 15, f"idle grace outlived --timeout {timeout_s}s: closed after {close_delay:.0f}s\n{stderr}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -417,28 +417,37 @@ def _add_submodule(wt: Path, tmp_path: Path) -> None:
|
|||||||
_git(wt, "commit", "-q", "-m", "add sm")
|
_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):
|
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
|
main, wt = wt_repo
|
||||||
base = _impl(wt)
|
base = _impl(wt)
|
||||||
_add_submodule(wt, tmp_path)
|
_add_submodule(wt, tmp_path)
|
||||||
if when == "before-ignored": # no ignore setting hides the dirty content
|
if when == "before-ignored": # no ignore setting hides the dirty content
|
||||||
_git(wt, "config", "submodule.sm.ignore", "all")
|
_git(wt, "config", "submodule.sm.ignore", "all")
|
||||||
_git(wt, "config", "diff.ignoreSubmodules", "all")
|
_git(wt, "config", "diff.ignoreSubmodules", "all")
|
||||||
|
if when.endswith("-hidden"):
|
||||||
|
_git(wt / "sm", "config", "status.showUntrackedFiles", "no")
|
||||||
|
|
||||||
def dirty(*_):
|
def dirty(*_):
|
||||||
(wt / "sm" / "x.txt").write_text("unreviewed work\n")
|
(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"):
|
if when.startswith("before"):
|
||||||
dirty()
|
dirty()
|
||||||
elif when == "untracked":
|
elif when.startswith("untracked"):
|
||||||
(wt / "sm" / "new.txt").write_text("untracked in the submodule\n")
|
untracked()
|
||||||
|
on_review = {"during": dirty, "during-hidden": untracked}.get(when)
|
||||||
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"],
|
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) == []
|
assert rc == 1 and _sidecars(main) == []
|
||||||
err = capsys.readouterr().err
|
err = capsys.readouterr().err
|
||||||
assert "submodule(s) with uncommitted or untracked content: sm" in 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
|
assert len(_reviews(calls)) == 1 and r._rounds[0]["stale"] is True
|
||||||
else:
|
else:
|
||||||
assert _reviews(calls) == [], "refused before any review"
|
assert _reviews(calls) == [], "refused before any review"
|
||||||
|
|||||||
@@ -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):
|
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
|
main = ctl_sub
|
||||||
wt = _wt(main)
|
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)
|
res = _prepare(main, wt)
|
||||||
assert res.returncode == 0, res.stdout + res.stderr
|
assert res.returncode == 0, res.stdout + res.stderr
|
||||||
|
assert expect in res.stdout
|
||||||
assert os.path.realpath(wt) in _worktrees(main)
|
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()
|
excl = (main / ".git" / "info" / "exclude").read_text().splitlines()
|
||||||
assert excl.count("/.venv") == 1 and excl.count(".pytest_cache/") == 1
|
assert excl.count("/.venv") == 1 and excl.count(".pytest_cache/") == 1
|
||||||
assert _git(wt, "status", "--porcelain").stdout == ""
|
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
|
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):
|
def test_w2_prepare_refusals_and_dry_run(ctl):
|
||||||
main = ctl
|
main = ctl
|
||||||
res = _run(main, PREP, "--path", str(main / "inside"), "--branch", "x")
|
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
|
# 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
|
"""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
|
main = ctl_sub
|
||||||
wt = _wt(main)
|
wt = _wt(main)
|
||||||
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
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)}]")
|
_mutate(main, f"d['herdr_sessions'] = [{_row(main, 'late-creator-claude', wt)}]")
|
||||||
late = wt / "sm" / "late-user-work.txt"
|
late = wt / "sm" / "late-user-work.txt"
|
||||||
stop = Path(_skill(main, "multi-agent-mux-stop", "scripts", "stop_session.sh"))
|
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)
|
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):
|
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
|
"""R2: a stash in a submodule is reachable from no branch and leaves the
|
||||||
status clean; it must still refuse the forced removal."""
|
status clean; it must still refuse the forced removal."""
|
||||||
|
|||||||
@@ -497,6 +497,11 @@ def test_importing_delegation_does_not_mutate_environment(mam_sandbox):
|
|||||||
assert set(os.environ) == keys
|
assert set(os.environ) == keys
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_help(text: str) -> str:
|
||||||
|
text = text.replace("optional arguments:", "options:")
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
def test_shim_help_output_matches_pre_move_capture():
|
def test_shim_help_output_matches_pre_move_capture():
|
||||||
"""The shim must be indistinguishable from the implementation at the CLI.
|
"""The shim must be indistinguishable from the implementation at the CLI.
|
||||||
|
|
||||||
@@ -512,7 +517,12 @@ def test_shim_help_output_matches_pre_move_capture():
|
|||||||
capture_output=True, text=True, timeout=60,
|
capture_output=True, text=True, timeout=60,
|
||||||
)
|
)
|
||||||
assert res.returncode == 0, res.stderr
|
assert res.returncode == 0, res.stderr
|
||||||
assert res.stdout + res.stderr == golden, "--help drifted for {}".format(mod)
|
# argparse re-wraps usage/help text and renamed the "optional arguments"
|
||||||
|
# group across Python versions (Issue #28: the captures were taken on
|
||||||
|
# 3.9, the Linux host runs 3.14). Compare the content, not the layout:
|
||||||
|
# whitespace runs collapsed, the group label unified.
|
||||||
|
assert _normalize_help(res.stdout + res.stderr) == _normalize_help(golden), \
|
||||||
|
"--help drifted for {}".format(mod)
|
||||||
|
|
||||||
|
|
||||||
# --- PV-4: the context matrix, each scenario in a fresh interpreter ------------
|
# --- PV-4: the context matrix, each scenario in a fresh interpreter ------------
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ class TestWorkspaceScope(unittest.TestCase):
|
|||||||
lib_sh = os.path.join(repo_root, ".agents", "skills", "lib.sh")
|
lib_sh = os.path.join(repo_root, ".agents", "skills", "lib.sh")
|
||||||
|
|
||||||
cmd = f"source {lib_sh} && env -u HERDR_SESSION_NAME -u HERDR_SERVER_NAME bash -c 'source {lib_sh} && resolve_herdr_session \"test-session\" \"{self.ws_dir}\"'"
|
cmd = f"source {lib_sh} && env -u HERDR_SESSION_NAME -u HERDR_SERVER_NAME bash -c 'source {lib_sh} && resolve_herdr_session \"test-session\" \"{self.ws_dir}\"'"
|
||||||
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
# `source` is a bash builtin; on hosts where /bin/sh is dash `shell=True`
|
||||||
|
# fails with "source: not found" (Issue #28), so run the string under bash.
|
||||||
|
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
|
||||||
self.assertEqual(res.returncode, 0, f"Command failed: {res.stderr}")
|
self.assertEqual(res.returncode, 0, f"Command failed: {res.stderr}")
|
||||||
parent = os.path.basename(os.path.dirname(os.path.abspath(self.ws_dir))).lower().replace('_', '-')
|
parent = os.path.basename(os.path.dirname(os.path.abspath(self.ws_dir))).lower().replace('_', '-')
|
||||||
work = os.path.basename(os.path.abspath(self.ws_dir)).lower().replace('_', '-')
|
work = os.path.basename(os.path.abspath(self.ws_dir)).lower().replace('_', '-')
|
||||||
|
|||||||
Reference in new issue
Block a user