apply-patch, 모델을 위해 설계한 diff
모델은 줄 번호 없는 패치를 채우고, 사람이 보는 건 사후에 계산한 unified diff예요. 같은 수정, 두 가지 형식 — 각자 어디서 엎어지는지 보세요.
@@ -l,s +l,s 네 숫자가 아니라 컨텍스트 앵커 한 줄만 쓰는지, 그리고 컨텍스트가 안 맞을 때 왜 파일 하나는 통째로 디스크에 안 쓰이며, 여러 파일에 걸쳐서는 그 보장이 왜 성립하지 않는지요.
greet 안의 pass를 return 123으로 바꾸기- 문법의
@@는 앵커만 있고 시작 줄과 폭은 없어요parser.rs L20 - 파일을 갱신하는 chunk는 파일에 나타나는 순서대로여야 해요parser.rs L74
change_context가 있으면line_index부터 아래로 이 줄을 찾아요file_update.rs L99- 먼저 줄 전체 정확 비교, 그다음 줄 끝 공백을 지우고, 그다음 양쪽 trimseek_sequence.rs L40
- 앵커를 못 찾으면 바로 Failed to find context를 내고, 근처 줄로 짐작하지 않아요file_update.rs L109
- 한 파일의 chunk를 메모리에서 다 계산한 뒤에야 write_file을 호출해요lib.rs L695
- 파일 여러 개에서 실패하면 이미 커밋된 delta를 들고 돌아오고, 롤백은 없어요lib.rs L453
- 사람이 보는 unified diff는 사후에 TextDiff로 따로 계산해요file_update.rs L328
@@ 헤더는 옛 시작·옛 폭·새 시작·새 폭을 한꺼번에 맞춰야 해요. 위에 두 줄을 넣으면 네 숫자가 함께 죽어요.@@ def greet():만 쓰고 런타임에 찾아요. 두 줄을 넣어도 맞아요. 줄 번호가 이 형식에 들어오지 않았거든요.모델에게 함수 하나를 고치게 해요. greet 안의 pass를 return 123으로. 표준 unified diff를 뱉고 첫 줄을 @@ -47,3 +47,3 @@로 씁니다.
방금 다른 편집이 두 줄을 넣어서 greet는 이미 49행이에요. 모델은 줄 번호가 있는 발췌에서 세었고, 패치를 쓸 때 시작 줄과 폭을 다시 더해야 해요. 네 숫자가 함께 틀리는 게 일상입니다.
patch(1)은 줄 번호로 찾고, 없으면 fuzz해요. fuzz도 실패하면 패치 전체가 죽어요. 더 골치 아픈 건 이웃 함수에 변경을 붙일 수 있다는 점이에요. 테스트는 초록인데, 틀린 함수를 고친 거죠.
Codex는 채우기와 읽기를 갈라요. 모델이 채우는 쪽에는 줄 번호가 없어요. 한 구간을 갱신할 때 첫 줄은 @@ def greet():뿐이에요. 홀로 있는 @@는 현재 위치부터 계속 찾으라는 뜻이고, 위치 잡기는 런타임의 seek_sequence에 맡깁니다.
사람이 변경을 볼 때, UI는 similar::TextDiff로 표준 unified diff를 따로 만들어요. 도구 인자에 있던 Codex 형식은 여기서 이미 쓰임을 다합니다.
codex-rs/apply-patch/src/file_update.rs 328–329행
이 문법은 ‘줄 번호 없음’을 생성 규칙에 넣었어요. @@ 두 가지 쓰기 모두 텍스트 앵커만 달고 있어요.
//! change_context: ("@@" | "@@ " /(.+)/) LF
//! change_line: ("+" | "-" | " ") /(.+)/ LF
//! eof_line: "*** End of File" LF
openai/codex 기준, 확인 파일 codex-rs/apply-patch/src/parser.rs, commit 4f39251a01, 확인일 2026-08-22. 코드 블록은 소스 원문을 유지합니다. 이 세 줄이 모델에게 주는 diff 헤더예요. 앵커는 있고 줄 번호는 없습니다.모델에게 보내는 설명서는 이 언어를 “stripped-down, file-oriented diff format designed to be easy to parse and safe to apply”라고 적어요. Add, Delete, Move는 문법의 세 표시이고, 파서가 표시대로 나눕니다. 모델은 ---, +++, /dev/null, rename 헤더를 어떻게 붙이는지 외울 필요가 없어요.
좌표는 덧셈이고, 내용은 인식이에요. 모델이 줄 번호를 세는 일은 모델을 바꾸든 언어를 바꾸든 나아지지 않아요. ‘어느 구간인가’를 채울 때의 산술에서 적용할 때의 문자열 검색으로 옮기는 이 분업은 Rust에도, unified diff라는 구체 형식에도 기대지 않습니다.
모델이 패치를 쓸 때 줄 끝에 공백 하나, 또는 파일은 en-dash인데 빼기로 쓰는 일은 흔한 사고예요. 매번 통째로 실패하면 모델은 다시 쓸 수밖에 없어요. 줄 번호 근처를 몇 줄 더 시험하면, fuzz가 틀린 함수에 붙는 옛길로 돌아갑니다.
seek_sequence는 네 단계, 촘촘한 쪽에서 느슨한 쪽으로 찾아요. 1단계 줄 전체 정확 일치. 2단계 줄 끝 공백을 지우고 비교. 3단계 양쪽 trim(). 4단계 흔한 Unicode 짧은 가로와 굽은 따옴표를 ASCII로. 네 단계가 모두 실패하면 빈 값을 돌려 “Failed to find context” 또는 “Failed to find expected lines”를 냅니다. 근처 몇 줄을 다시 시험하는 루프는 없어요.
codex-rs/apply-patch/src/seek_sequence.rs 40–114행
초기에 사고 하나가 있어서, 이상한 Unicode 문자 때문에 4단계를 넣었어요. 공백과 문장부호만 느슨하게 하고 위치는 느슨하게 하지 않아요. 전각 중문 따옴표는 정규화 표에 없어서, 모델이 전각 왼쪽 따옴표를 쓰고 파일은 반각이면 네 단계가 모두 실패합니다.
관용은 차이 두 종류를 갈라야 해요. 줄 끝 공백과 굽은 따옴표는 의미 없는 바이트 차이라 정규화해도 돼요. 줄 번호가 두 줄 어긋난 것은 틀린 자리에 붙는 일이니, 오류를 내고 모델이 다시 쓰게 해야 해요. 이 경계는 다른 언어로 다시 써도 성립합니다.
패치 하나에 chunk가 둘이에요. 두 번째가 안 맞는데 첫 번째는 이미 들어갔으면 파일이 반제품이 돼요. 디버그하는 사람은 절반만 적용된 파일을 보게 되고, 통째 실패보다 고치기 더 어려워요.
한 파일 안에서 compute_replacements는 각 chunk를 먼저 치환 목록으로 모아요. 하나라도 안 맞으면 바로 오류를 돌려 write_file까지 가지 않아요. 그 파일은 그대로입니다.
codex-rs/apply-patch/src/file_update.rs 109–113행
여러 파일은 다른 이야기예요. apply_hunks_to_files는 hunk 순서로 디스크에 쓰고, 실패하면 이미 커밋된 AppliedPatchDelta를 들고 돌아와요. 루프에 롤백은 없습니다. 테스트 015가 이걸 못 박았어요. 먼저 created.txt 추가에 성공한 뒤 없는 경로를 갱신하면, 디스크의 created.txt는 그대로 남아요.
codex-rs/apply-patch/src/lib.rs 453행, 그리고 504행부터의 hunk 루프
계산한 뒤 커밋하는 범위는, 원자적으로 다룰 수 있는 단위와 맞춰야 해요. 파일 하나는 메모리에서 치환을 다 계산한 다음 한 번 쓰면 돼요. 여러 파일은 이미 디스크에 떨어져 있어서, 롤백은 다시 써야 하고 Move처럼 출발과 도착이 모두 움직인 반성공도 처리해야 해요. 파일 사이 트랜잭션을 둘지는 제품 선택이지, 형식 자체의 약속이 아닙니다. 모델에게 주는 설명에서 한 파일의 보장을 전역 보장처럼 말하지 마세요.
DeepSeek Harness: 먼저 읽어야 고칠 수 있어요
DSH의 editIntent는 이 세션이 그 파일을 관측했는지 봐요. 관측이 없으면 FS_NOT_OBSERVED를 던져요. 기록하는 것은 dev:ino:size:mtimeNs:ctimeNs를 이은 버전이지, 내용 해시가 아닙니다.
그 문을 지나도 applyLiteralEdit는 기본적으로 old_string이 한 번만 나오길 바라고, 여러 곳에 맞으면 FS_AMBIGUOUS_EDIT를 던져요. 실수 방지는 이벤트 문과 리터럴 유일성에 걸려 있어요. Codex에는 먼저 읽기 제약이 없고, 위치 정보는 패치에 쓰여 런타임에 찾으며, old_lines가 두 번 나오면 첫 곳을 쓰고 모호함 오류는 없습니다.
Claude Code: 안 읽었으면 거절, 여러 곳에 맞아도 거절
FileEditTool은 두 가지를 동시에 요구해요. 파일은 먼저 읽어야 하고, 안 읽었으면 errorCode 6, 원문은 File has not been read yet. old_string이 파일에 두 곳 이상이고 replace_all이 거짓이면 errorCode 9를 내며, 이 한 곳을 따로 표시하도록 컨텍스트를 더 달라고 해요.
퍼지는 따옴표만 덮어요. findActualString은 먼저 정확히 찾고, 그다음 굽은 따옴표를 곧은 따옴표로 접어 찾아요. Codex처럼 줄 끝 공백 세 단계도, 짧은 가로 정규화도 없어요. 도구 왕복을 한 번 줄이려면 Codex 형식을 베끼고, 모델에게 오류를 더 구체적으로 주려면 이 errorCode 거절 문구를 베끼세요.
같은 old_lines가 두 단락일 때, 어느 쪽을 고치나
파일에 완전히 같은 old_lines가 두 단락 있고, 모델은 둘째만 고치고 싶은데 @@ 앵커가 부족해요. seek_sequence는 어느 단락을 고치고, 왜일까요?
심화 한 질문: 둘째 단락만 맞추려면 앵커를 어느 줄 앞에 써야 할까요? 같은 패치에서 파일 하나 추가에 성공한 뒤 없는 경로를 갱신하면, 디스크에는 무엇이 남나요?