fix(loop): resolve P0-1 (B-7) git diff CWD isolation and untracked file capture (20/20 PASS)

This commit is contained in:
2026-08-11 09:39:57 +09:00
parent 64cde54e89
commit f8084cd927
8 changed files with 533 additions and 23 deletions
@@ -0,0 +1,86 @@
# Cross-Code Review — Job `08666ed7`
- **Job ID**: 08666ed7 · **Reviewer**: cline · **Base**: `64cde54` (working-tree, uncommitted)
- **Task**: P0-1 (B-7) 결함 수정 구현 리뷰 — `run_loop.sh`·`diff_collect.sh`(신규)·`tests/test_b7_diff_untracked.py`(신규) 및 누적 변경분 (lint / 동작성 / 유실)
- **Diff scope**: 추적 파일 3개 수정(`run_loop.sh` +9/-9, `deploy/gitea-ci.yml` +1, `tests/test_tier4_e2e.py` +8) + 비추적 신규 2개(`diff_collect.sh` 132줄, `tests/test_b7_diff_untracked.py` 275줄/20테스트)
---
## 1. 변경분 요약 및 검증 대상
B-7(저장소 밖 기동 시 리뷰어가 `"No git diff available"` 문자열만 받고 `[VERDICT: PASS]` 를 내는 결함 + 미추적 신규 파일 누락)의 수정이다. 핵심 구조:
1. **`diff_collect.sh`(신규, 132줄)** — 변경수집 단일 진실원. `mam_collect_changes_diff(repo_root, base_commit)``cd -P "$repo_root"``git diff "$base_commit"`(추적) + `git ls-files -o --exclude-standard -z``git diff --no-index -- /dev/null "$f"`(미추적) 를 합산. `git add -N` 미사용(인덱스 비변경). 크기 상한(200KB/4000행) 초과 시 `!!! DIFF TRUNCATED !!!` 마커 + `--stat` 요약 + "You have NOT been shown the full change set" 명시. 비-git → `!!! CHANGE SET UNAVAILABLE !!!` + reason, rc=2(fail-closed). 심볼릭 링크·중첩 git 저장소·디렉터리는 마커로 공지.
2. **`run_loop.sh`** — `diff_collect.sh` source 추가; `BASE_COMMIT``cd -P "$REPO_ROOT" && git rev-parse HEAD`(cwd 비의존); 리뷰어 루프 **밖**에서 `CHANGES_DIFF=$(mam_collect_changes_diff …)` 1회 산출 + `|| { exit 1; }` fail-closed; 루프 내 구 `git diff` 인라인 블록(6줄) 제거.
3. **`deploy/gitea-ci.yml`** — `shellcheck …/diff_collect.sh` 추가(린트 사각지대 폐쇄).
4. **`tests/test_tier4_e2e.py`** — e2e 샌드박스에 `git init` + 초기 커밋 추가(신규 fail-closed 경로 대응).
5. **`tests/test_b7_diff_untracked.py`(신규, 20테스트)** — cwd 독립·비-git fail-closed·인덱스 비변경·`commit -am` 안전·truncation·gitignore·중첩 repo·심볼릭 링크·단일산출 구조 검증.
| 검증 항목 | 방법 | 결과 |
|---|---|---|
| `git status` 범위 | `git status --porcelain` | 추적 3 + 비추적 2 = 5파일 |
| b7 테스트 | `pytest tests/test_b7_diff_untracked.py -q` | **20 passed in 1.98s** |
| 광역 회귀 | `pytest test_tier1_unit test_tier2_component test_b7 -q` | **75 passed in 231.05s** (회귀 0) |
| e2e | `pytest tests/test_tier4_e2e.py -q` | **5 passed in 115.33s** (git-init 대응 정상) |
---
## 2. Lint (정적 품질)
- **`bash -n diff_collect.sh`** → rc=0 (문법 정상). test_b7_14 가 `bash -n` 양 스크립트를 자동 검증.
- **`bash -n run_loop.sh`** → rc=0 (test_b7_14 검증).
- **shellcheck**: 본 환경에 미설치(`command not found`, rc=127)로 로컬 실행 불가. 단 `deploy/gitea-ci.yml``shellcheck …/diff_collect.sh` 가 추가되어 CI에서 검증됨. 코드는 shellcheck 친화 패턴(따옴표 필수, `--` 구분자, `local` 선언, `[[ ]]`/`[ -n ]` 정규 테스트) 준수.
- **Python 테스트**: pytest 수집(clean import), 휴 스터디·임포트 누락 없음.
- **구조**: `diff_collect.sh``if [ "${BASH_SOURCE[0]}" = "$0" ]` 가드로 source 시 부작용 0(`set -e` 미선언 → source 안전). `run_loop.sh``set -euo pipefail` 하에서 `mam_collect_changes_diff … || { … }``||` 리스트 예외로 errexit 안전.
**Lint 결과: PASS** (shellcheck 로컬 미실행은 환경 제약; CI 등록으로 보완됨)
---
## 3. 동작성 (B-7 처방 대비 실측)
| B-7 처방(로드맵 §6.4) | 구현 실측 | 판정 |
|---|---|---|
| `cd "$REPO_ROOT"` 로 cwd 의존 제거 | `BASE_COMMIT` `cd -P "$REPO_ROOT" && …` + 함수 내 `cd -P "$repo_root"` | ✅ test_b7_6(외부 cwd 실행) 통과 |
| 미추적 파일 `git ls-files -o --exclude-standard` + `git diff --no-index` | `_mam_untracked_diff` 정확히 해당 | ✅ test_b7_1(내용)·test_b7_20 통과 |
| `git add -N .` **미채택**(인덱스 오염) | 미사용; test_b7_4(인덱스 `??` 유지)·test_b7_5(`commit -am` 안전) | ✅ 인덱스 비변경 입증 |
| 크기 상한 + 잘렸다는 사실 노출 | 200KB/4000행(env 가변); 초과 시 TRUNCATED 마커 + "NOT shown the full change set" | ✅ test_b7_9·test_b7_10 통과 |
| 빈 diff / 비-git 구분 | 빈→"(no changes)" rc=0(정직 신호); 비-git→UNAVAILABLE rc=2 | ✅ test_b7_7·test_b7_8 통과 |
| fail-closed (잘못된 PASS 차단) | run_loop.sh `‖ { exit 1; }` — 리뷰 요청 자체 중단 | ✅ test_b7_12 통과 — 핵심 결함 정정 |
| gitignore 존중 | `--exclude-standard` 적용 | ✅ test_b7_3·test_b7_17 통과 |
| 심볼릭 링크·중첩 repo 공지 | 마커 출력, 확장 안 함 | ✅ test_b7_16~b7_19 통과 |
| 단일 산출(루프 내 중복 제거) | 루프 외 1회; test_b7_13 구조 단언 | ✅ 효율·일관성 개선 |
**회귀**: tier1+tier2+b7 75/75, tier4 e2e 5/5 — 신규 fail-closed 경로가 e2e 샌드박스(git init 추가)에서 정상 동작함.
**동작성 결과: PASS** — B-7 처방 9개 항목 전부 구현·검증됨.
---
## 4. 유실 (Loss / Orphan)
- 루프 내 구 `git diff` 인라인 블록(조건문 6줄 + 주석 3줄) 제거 — `CHANGES_DIFF` 는 루프 전 1회 설정 후 루프 내 소비로 orphan 없음.
- `"No git diff available"` 문자열: `grep -rn`**0건** (오해 유발 fallback 완전 제거).
- `mam_collect_changes_diff`: 정의 1회(diff_collect.sh:55) + 자기호출 가드(:131) + 호출 1회(run_loop.sh:525). 복제 없음.
- `test_tier4_e2e.py`: 순수 추가(+8줄), 삭제 없음.
- 신규 자산에 대한 orphan 임포트/변수 없음.
**유실 결과: PASS** — 부당 삭제/잔재 없음.
---
## 5. 비차단 발견 (Non-blocking Findings)
**N-1 (shellcheck 로컬 미실행, 환경 제약).** 본 환경에 shellcheck 미설치로 diff_collect.sh 경고를 로컬에서 확인하지 못함. CI(`gitea-ci.yml`)에 등록됐으므로 원격 검증될 것이나, 가능하면 로컬에 shellcheck 설치 후 0-경고 확인 권고. 비차단.
**N-2 (빈 변경수 = 리뷰 진행, 설계 선택).** `mam_collect_changes_diff` 는 진짜 빈 diff(유효 repo·변경 0)를 rc=0 `"(no changes since base commit)"` 로 반환해 리뷰를 진행시킨다(fail-closed 아님). 비-git·git 장애만 rc=2 로 중단. 이는 "변경 없음" 을 리뷰어에게 정직히 보여 판단을 맡기는 합리적 선택이나, 향후 "변경 0건인데 리뷰 요청" 자체를 차단할지는 정책 결정 여지. 현재 결함(B-7) 대상 아님. 비차단.
**N-3 (미추적 파일 O(files) 서브프로세스).** `_mam_untracked_diff` 가 파일마다 `git diff --no-index` 를 spawn(루프). 전형적 리뷰 규모에선 문제 없고 크기 상한이 상한을 묶으나, 수백 신규 파일 시 spawn 비용 증가. 관측된 바 없음. 비차단.
---
## 6. 종합 판정
B-7 결함 수정은 로드맵 §6.4 처방을 정확히 구현했다: cwd 비의존화, `git add -N` 배제한 미추적 파일 포함, 인덱스 비변경, 크기 상한 + 잘림 명시, 비-git fail-closed(`exit 1`), `"No git diff available"` 오해 문자열 완전 제거. 신규 `diff_collect.sh`(132줄)는 단일 진실원으로 source/실행 겸용 가드를 갖추고, `run_loop.sh` 는 루프 외 1회 산출로 효율과 일관성을 개섰다. 20개 전용 테스트 + 광역 회귀 75/75 + e2e 5/5 전부 통과해 회귀 0임을 입증했다. CI 린트 등록으로 사각지대도 폐쇄했다. 부당 삭제나 orphan 없고, 설계 재작업이 필요한 근거(escalation)도 발견되지 않는다 — 단순 버그 수정 범주를 벗어나지 않는 철저한 구현이다.
[VERDICT: PASS]
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# diff_collect.sh — Helper module for collecting git changes and untracked files (B-7)
# Single source of truth for collecting review diffs.
MAM_DIFF_UNAVAILABLE_MARKER="!!! CHANGE SET UNAVAILABLE !!!"
MAM_DIFF_TRUNCATED_MARKER="!!! DIFF TRUNCATED !!!"
MAM_DIFF_NESTED_MARKER="!!! NOT EXPANDED IN THIS DIFF !!!"
MAM_DIFF_MAX_BYTES="${MAM_DIFF_MAX_BYTES:-200000}"
MAM_DIFF_MAX_LINES="${MAM_DIFF_MAX_LINES:-4000}"
_mam_nested_dir_diff() {
local d="$1" outer_top="$2" inner_top=""
inner_top=$(git -C "$d" rev-parse --show-toplevel 2>/dev/null || printf '')
if [ -n "$inner_top" ] && [ "$inner_top" != "$outer_top" ]; then
printf '%s %s\n' "$MAM_DIFF_NESTED_MARKER" "$d"
printf 'Untracked NESTED GIT REPOSITORY. Its contents are listed through its own\n'
printf 'git, so its .gitignore applies; the outer diff cannot describe it.\n\n'
git -C "$d" diff 2>/dev/null || true
git -C "$d" ls-files -o --exclude-standard -z 2>/dev/null | while IFS= read -r -d '' s; do
[ -n "$s" ] || continue
git -C "$d" diff --no-index -- /dev/null "$s" 2>/dev/null || true
done
printf '\n'
return 0
fi
printf '%s %s (untracked directory)\n' "$MAM_DIFF_NESTED_MARKER" "$d"
git ls-files -o --exclude-standard -z -- "$d" 2>/dev/null | while IFS= read -r -d '' s; do
[ -n "$s" ] || continue
[ -f "$s" ] && [ ! -L "$s" ] || continue
git diff --no-index -- /dev/null "$s" 2>/dev/null || true
done
}
_mam_untracked_diff() {
local outer_top
outer_top=$(git rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PWD")
git ls-files -o --exclude-standard -z 2>/dev/null | while IFS= read -r -d '' f; do
[ -n "$f" ] || continue
if [ -L "$f" ]; then
printf '%s %s -> %s (symlink, not expanded)\n' \
"$MAM_DIFF_NESTED_MARKER" "$f" "$(readlink "$f" 2>/dev/null)"
elif [ -d "$f" ]; then
_mam_nested_dir_diff "$f" "$outer_top"
elif [ -f "$f" ]; then
git diff --no-index -- /dev/null "$f" 2>/dev/null || true
else
printf '%s %s (unreadable entry, not shown)\n' "$MAM_DIFF_NESTED_MARKER" "$f"
fi
done
}
mam_collect_changes_diff() {
local repo_root="${1:-}"
local base_commit="${2:-}"
if [ -z "$repo_root" ]; then
printf '%s\nreason: repo_root parameter is required\n' "$MAM_DIFF_UNAVAILABLE_MARKER"
return 2
fi
if [ ! -d "$repo_root" ]; then
printf '%s\nreason: repo_root directory %s does not exist\n' "$MAM_DIFF_UNAVAILABLE_MARKER" "$repo_root"
return 2
fi
local raw_diff="" rc=0
raw_diff=$(
cd -P "$repo_root" 2>/dev/null || exit 3
git rev-parse --git-dir >/dev/null 2>&1 || exit 4
if [ -n "$base_commit" ] && git cat-file -e "${base_commit}^{commit}" 2>/dev/null; then
git diff "$base_commit" || exit 5
else
git diff || exit 5
fi
_mam_untracked_diff
) || rc=$?
if [ "$rc" -ne 0 ]; then
local reason="not a git repository or git command failed (exit code $rc)"
if [ "$rc" -eq 3 ]; then
reason="cannot cd into repo_root $repo_root"
elif [ "$rc" -eq 4 ]; then
reason="not a git repository: $repo_root"
elif [ "$rc" -eq 5 ]; then
reason="git diff command failed"
fi
printf '%s\nreason: %s\n' "$MAM_DIFF_UNAVAILABLE_MARKER" "$reason"
return 2
fi
if [ -z "$(printf '%s' "$raw_diff" | tr -d '[:space:]')" ]; then
printf '(no changes since base commit)\n'
return 0
fi
local byte_cnt line_cnt
byte_cnt=$(printf '%s' "$raw_diff" | wc -c | tr -d '[:space:]')
line_cnt=$(printf '%s' "$raw_diff" | wc -l | tr -d '[:space:]')
if [ "$byte_cnt" -gt "$MAM_DIFF_MAX_BYTES" ] || [ "$line_cnt" -gt "$MAM_DIFF_MAX_LINES" ]; then
local stat_summary=""
stat_summary=$(
cd -P "$repo_root" 2>/dev/null || exit 0
if [ -n "$base_commit" ] && git cat-file -e "${base_commit}^{commit}" 2>/dev/null; then
git diff "$base_commit" --stat 2>/dev/null || true
else
git diff --stat 2>/dev/null || true
fi
printf '\nUntracked files:\n'
git ls-files -o --exclude-standard 2>/dev/null || true
)
printf '%s\n' "$MAM_DIFF_TRUNCATED_MARKER"
printf 'The change set is %s bytes / %s lines, over the review limit (%s bytes / %s lines).\n' \
"$byte_cnt" "$line_cnt" "$MAM_DIFF_MAX_BYTES" "$MAM_DIFF_MAX_LINES"
printf 'Only the file-level summary is shown below. You have NOT been shown the\n'
printf 'full change set -- inspect the working tree directly before voting.\n\n'
printf '%s\n\n' "$stat_summary"
printf '%s\n' "$MAM_DIFF_TRUNCATED_MARKER"
return 0
fi
printf '%s\n' "$raw_diff"
return 0
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
mam_collect_changes_diff "$@"
fi
@@ -10,6 +10,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
# shellcheck disable=SC1091
source "$REPO_ROOT/.agents/skills/lib.sh"
# shellcheck disable=SC1091
source "$REPO_ROOT/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh"
# Default configuration parameters
PLAN_MODE=false
@@ -428,7 +430,7 @@ log_info "=== Phase 2: Code Implementation ==="
# Fix the pre-implementation commit as the diff baseline so review diffs stay
# cumulative and non-empty even after the Creator commits per DoD (P0-1).
BASE_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "")
BASE_COMMIT=$(cd -P "$REPO_ROOT" 2>/dev/null && git rev-parse HEAD 2>/dev/null || echo "")
EXECUTION_PROMPT="계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: $TASK"
if [ -n "$CURRENT_PLAN" ]; then
@@ -520,6 +522,12 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
else
log_info "Active reviewers: ${REVIEWERS[*]}"
CHANGES_DIFF=$(mam_collect_changes_diff "$REPO_ROOT" "$BASE_COMMIT") || {
log_error "Could not determine the change set; refusing to request a review on no evidence."
log_error "$CHANGES_DIFF"
exit 1
}
# We use space-separated lists or simple loops to bypass bash-4 associative array requirement (M-7 macOS compatibility)
declare -a JOB_IDS=()
declare -a JOB_REVS=()
@@ -530,14 +538,6 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
for rev in "${REVIEWERS[@]}"; do
log_info "Requesting code review from Reviewer '$rev'..."
# Cumulative diff since BASE_COMMIT (M-6): includes committed AND
# uncommitted changes, so it stays non-empty even after the Creator
# commits per the documented DoD (bare `git diff` alone would not).
if [ -n "$BASE_COMMIT" ]; then
CHANGES_DIFF=$(git diff "$BASE_COMMIT" 2>/dev/null || echo "No git diff available")
else
CHANGES_DIFF=$(git diff 2>/dev/null || echo "No git diff available")
fi
REV_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$rev" \