LLM 작업실
글의 작성일과 수정일
수정일을 git 커밋 시각에서 계산하되 기계적 커밋은 건너뛰고, 헤더에 작성일과 나란히 보이는 구조
관련 파일: _plugins/, .github/, _includes/, _layouts/, _sass/
글 머리에는 날짜가 둘 있다. 작성일은 프론트매터의 date이고, 수정일은 last_modified_at이다. 수정일은 헤더의 “수정일” 행 말고도 seo.html의 dateModified·article:modified_time, 사이트맵의 <lastmod>, 최근 글 목록의 날짜 표시가 같이 읽는다.
예전에는 이 값을 글마다 프론트매터에 손으로 적었다. 번역 워커가 본문을 갈아 끼우고 일괄 작업이 수십 편을 한꺼번에 건드리는 동안, 그 줄은 누가 챙겨 올려 주지 않으면 처음 값 그대로 멈춰 있었다. 화면의 수정일이 실제와 몇 주씩 어긋났는데, 틀린 것보다 틀린 줄 모르고 믿게 된다는 쪽이 문제였다. 사용자는 이 칸을 사람이 아니라 git에 맡기기로 했다. 지금 _plugins/의 정책은 두 줄이다.
- 프론트매터에
last_modified_at이 적혀 있으면 그 값이 이긴다. git이 틀린 드문 경우를 위한 수동 덮어쓰기다. - 없으면 그 파일을 건드린 커밋 가운데 가장 최근 것의 시각을 쓴다. 단, 커밋 메시지에
[lastmod-skip]이나[dev]가 있는 커밋은 건너뛴다.
프론트매터 병합 순서와 젬의 Determinator
jekyll- 젬은 처음부터 깔려 있었다. 젬은 :post_init 시점에 각 문서의 last_modified_at에 Determinator 객체를 넣어 두고, 렌더할 때 그 객체가 git log -n 1 -- <file>을 불러 날짜를 낸다. 그런데 Jekyll은 그 다음에 프론트매터를 파싱해 병합하므로, 프론트매터에 날짜가 적혀 있으면 그 값이 젬의 객체를 덮어쓴다. 손으로 적은 낡은 값이 이기던 원인이 이 순서였다.
플러그인은 이 순서를 판별에 쓴다. :site, :post_read 훅은 모든 문서와 페이지를 읽어 프론트매터까지 병합한 직후, 렌더하기 전에 돈다. 그 시점에 값이 아직 Determinator이면 프론트매터에 날짜가 없다는 뜻이니 git에서 계산하고, 그 밖의 값(Date·Time·String)이면 작성자가 적은 값이니 그대로 둔다. 파일을 다시 열어 프론트매터를 확인할 필요가 없다.
Jekyll::Hooks.register(:site, :post_read) do |site|
ignore = Array(site.config.dig("last-modified-at", "ignore_commits")).map(&:to_s)
items = []
site.collections.each_value { |coll| items.concat(coll.docs) }
items.concat(site.pages)
items.each do |item|
next unless item.respond_to?(:data) && item.respond_to?(:path) && item.path
cur = item.data["last_modified_at"]
# (a) an author-supplied frontmatter date (anything but the gem's seed) wins
next unless cur.nil? || cur.is_a?(Jekyll::LastModifiedAt::Determinator)
t = GIT_TIME_CACHE[item.path] ||= commit_time(site.source, item.path, ignore)
if t
item.data["last_modified_at"] = t
elsif cur && File.exist?(abs = File.expand_path(item.path.to_s, site.source))
item.data["last_modified_at"] = Time.at(File.mtime(abs).to_i)
end
end
end
:post_read에서 한 번에 고치므로 집계 페이지도 같은 값을 본다. 최근 글 목록, 사이트맵, 피드는 렌더 시점에 다른 글의 last_modified_at을 읽는데, 그때는 이미 값이 바뀌어 있다. GIT_TIME_CACHE는 경로별 결과를 프로세스 수명 동안 메모이즈해서 jekyll serve --incremental의 리빌드마다 다시 계산하지 않게 한다.
git 이력이 없는 파일(로컬 미리보기만 읽는 gitignore된 비공개 연구 글 같은 것)에는 git에 묻지 않고 파일 mtime을 바로 넣는다. 젬의 Determinator를 그대로 두면 렌더 때 빈 답을 얻으려고 전체 이력을 한 번 훑고(0.13초쯤) 결국 mtime으로 떨어지는데, 그 값을 미리 넣어 두는 것이다. 그래서 이름에 last-modified-at을 단 젬이 지금 하는 일은 대부분 “여기엔 아무도 날짜를 적지 않았다”는 표지 노릇이다.
수정일 계산에서 빠지는 커밋
마지막 커밋 시각은 “마지막으로 무언가 바뀐 때”이지 “마지막으로 내용이 바뀐 때”가 아니다. 표기 일괄 치환, 이미지 참조의 .png→.svg 교체, 링크 표시명 보정은 전부 커밋을 남기지만 글의 내용은 그대로다. 이 빈틈은 TikZ 다이어그램의 SVG 빌드와 색 체계 옮기는 작업에서 드러났다. 수십 편에서 이미지 참조 한 줄씩만 바뀌었는데, autopush를 타고 커밋되자 그 글들의 수정일이 모두 작업 시각으로 튀었고 검색엔진에도 그렇게 신고됐다. 수정일을 사람에게서 git으로 옮겼더니 git의 노이즈가 그 자리에 들어온 것이다.
사용자가 정한 장치는 두 갈래다. 커밋 메시지에 마커가 있으면 그 커밋을 무시하고, 그 밖에 무시할 과거 커밋은 _config.yml의 last-에 SHA 접두로 적는다 (지금 설정에는 이 항목이 없다). 마커는 둘이다. [lastmod-skip]은 글 본문의 기계적 손질(표기, 링크, 병기)에, [dev]는 레이아웃·스타일·플러그인·스크립트 같은 블로그 개발 커밋에 붙는다. 수정일 계산에서는 효과가 같고, [dev]는 따로 blogdev-bot이 읽는 작업 큐 노릇도 한다.
SKIP_MARKERS = ["[lastmod-skip]", "[dev]"].freeze
def self.skip_commit?(sha, msg, ignore_shas)
return true if msg && SKIP_MARKERS.any? { |m| msg.include?(m) } # mechanical / dev commits
ignore_shas.any? { |s| !s.empty? && sha.start_with?(s) }
end
마커는 프론트매터의 낡은 줄을 치우는 문제도 풀었다. 그 줄들을 한 커밋으로 일괄 삭제하면 그 커밋이 모든 파일을 동시에 건드리므로, 각 파일의 “마지막 커밋”이 죄다 삭제 커밋이 되고 모든 글의 수정일이 그 시각 하나로 붕괴한다. 거짓말하는 날짜를 고치려다 전부 같은 거짓말로 만드는 셈이다. 그래서 마커가 없던 동안의 훅은 프론트매터와 상관없이 git 값으로 덮어썼고, 그 줄들은 아무 일도 하지 않는 채로 남아 있었다. 삭제 커밋에 [lastmod-skip]을 달면 플러그인이 그 커밋을 건너뛰고, 각 파일은 삭제 직전의 진짜 날짜를 그대로 가진다. 그렇게 한 커밋에서 623줄이 지워졌고, 같은 커밋에서 훅이 프론트매터 값을 존중하는 정책 1로 바뀌었다. 낡은 값이 모두 사라진 뒤라야 프론트매터를 믿어도 됐기 때문이다.
마커가 생기기 전의 오염은 되돌리지 못했다. 다이어그램 이전은 별도 커밋이 아니라 두 시간짜리 autopush 커밋 안에 다른 진짜 수정과 섞여 들어가 있어서, 그 커밋을 통째로 무시하면 같이 묶인 진짜 수정의 날짜까지 잃는다. 그 기간의 날짜는 그대로 남아 있고, 특정 글의 수정일이 거슬리면 정책 1의 덮어쓰기를 쓴다.
이력 한 번 훑기로 만드는 날짜 색인
commit_time은 파일마다 git log -- <file>을 부르지 않는다. 파일별 호출은 매번 전체 이력을 다시 훑어 Pi에서 한 번에 0.12초쯤 걸리고, 960번가량의 호출이 콜드 스타트 시간의 대부분이었다. 지금은 전체 이력에 git log를 한 번 돌려 경로→시각 색인을 만들고, 각 문서는 그 색인에서 자기 경로를 찾는다.
raw = IO.popen(["git", "-C", source.to_s, "log", "--no-merges", "--no-renames", "-z",
"--name-only", "--format=%x1e%H%x1f%cI%x1f%B"], err: File::NULL, &:read).to_s
raw.force_encoding(Encoding::UTF_8)
resolved = {}
pending = {} # path => [newest Time, commits seen]
raw.split("\x1e").each do |rec|
header, files = rec.split("\x00", 2)
next if header.nil? || files.nil?
sha, iso, msg = header.split("\x1f", 3)
next if sha.nil? || iso.nil?
t = (Time.parse(iso) rescue nil)
next if t.nil?
skip = skip_commit?(sha, msg, ignore_shas)
files.split("\x00").each do |f|
f = f.delete_prefix("\n")
next if f.empty? || resolved.key?(f)
st = (pending[f] ||= [t, 0])
st[1] += 1
if !skip
resolved[f] = t
pending.delete(f)
elsif st[1] >= MAX_WALK
resolved[f] = st[0]
pending.delete(f)
end
end
end
pending.each { |f, (newest, _)| resolved[f] = newest }
커밋 사이는 %x1e(RS)로, 머리 안의 SHA·시각·메시지는 %x1f(US)로 끊고, -z가 파일 이름 사이에 NUL을 넣는다. 커밋은 최신부터 오므로 한 경로는 자기를 건드린 커밋 가운데 건너뛰지 않은 첫 커밋의 시각을 갖는다. 건너뛸 커밋만 MAX_WALK(80)개를 만나거나 이력이 끝나면 본 것 가운데 가장 최근 시각으로 물러선다. --no-renames는 이름 바꾸기를 두 경로를 모두 건드린 커밋으로 보게 해서, 파일별 git log -- <path>와 같은 답을 낸다.
한 번 훑는 데 0.6초쯤 걸리고, 시작 빌드는 120초에서 15초로, 클린 빌드는 150초 안팎에서 75초로 줄었다. 바꾸기 전에 추적되는 모든 .md·.html에 대해 파일별 조회와 대조한 결과 어긋난 곳은 없었다. 색인에 없는 경로(추적되지 않는 파일, 생성 페이지)는 nil이 되고, 위 훅이 mtime으로 처리한다.
이 색인은 빌드하는 곳에 전체 이력이 있어야 성립한다. 얕은 클론에는 각 파일을 거슬러 올라갈 이력이 없어서 모든 글의 날짜가 한 시각으로 뭉개진다. 그래서 배포 워크플로의 체크아웃은 fetch-depth: 0이다.
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0 # full history so jekyll-last-modified-at reads real commit dates
애초에 이런 _plugins/*.rb가 빌드에서 도는 것 자체가 Jekyll 4와 Pagefind 검색에서 빌드를 GitHub Actions로 옮긴 덕이다. GitHub Pages의 기본 빌드는 임의 플러그인을 허용하지 않는다.
autopush의 커밋 분류와 네 버킷
마커를 손으로 다는 대량 작업은 문제가 없다 (/commit도 dev와 lastmod-skip을 나눠 커밋한다). 오염원은 autopush였다. ~/.local/는 두 시간마다 git add -A로 그동안 바뀐 것을 쓸어 담는데, 그 사이에 기계적 작업과 진짜 수정이 섞이면 커밋 하나에 마커를 달 수도 안 달 수도 없다. 달면 진짜 수정의 날짜가 죽고, 안 달면 기계적 변경이 날짜를 밀어 올린다. 사용자가 정한 답은 커밋을 쪼개는 것이었다. 지금 autopush는 바뀐 파일을 나눠 최대 네 커밋을 만든다.
auto_mech = [f for f in changed if is_noise(f)]
add_del = [f for f in changed if is_post_md(f) and f not in auto_mech and status.get(f) in ('A', 'D')]
to_classify = [f for f in changed if f not in auto_mech and f not in add_del]
verdict = classify_changes(to_classify) # {path: DEV|MECHANICAL|CONTENT}, 실패하면 None
# ... (None이면 멈춤) ...
mechanical = [f for f in changed if f in mech_set]
dev = [f for f in changed if f in dev_set]
content = [f for f in changed if f not in mech_set and f not in dev_set]
workshop = [f for f in changed if is_workshop(f)] # 나머지 세 버킷에서는 뺀다
커밋 제목은 [Auto] Mechanical (N files) [lastmod-, [Auto] Dev (N files) [dev], [Auto] Content (N files), [Auto] Workshop (N files)이고, 기계적 버킷부터 커밋한다. 분류기의 판정으로 버킷이 정해지지 않는 파일이 세 종류 있다.
- 봇이 생성하는 파일(
_data/terms.yml,Index_*페이지 등NOISE_PATTERNS)은 곧장 기계적 버킷으로 간다. - 새로 생기거나 지워진 글(
git diff --cached --name-의status A·D)은 곧장 내용 버킷으로 간다. 파일이 통째로 생기거나 사라진 것은 기계적 손질일 수 없고, 내용 버킷은 “기계적도 개발도 아닌 전부”라서 분류 목록에서 빼기만 하면 그리로 간다. 예전에는 새 글 한 편의 diff가 분류기 입력 한도를 넘겨 푸시 전체를 세우곤 했다. 답이 정해진 질문을 하느라 멈춰 서 있었던 셈이다. - 워크숍 글은 분류 결과와 무관하게 경로로 따로 뺀다. 분류기가 워크숍 글을 DEV로 부르면 그 글이
[dev]커밋에 들어가고, blogdev-bot의 작업 큐가 봇 자신의 글을 먹게 된다.
분류는 ~/.local/가 claude -p --model haiku로 한다. 로컬 모델로도 시도했지만 기계적인지 아닌지를 가리는 판정이 미덥지 않았다. 프롬프트는 파일마다 이 순서로 묻는다. 블로그의 기계 장치(레이아웃, SCSS, 플러그인, 스크립트, 설정과 그 문서)이면 DEV다. 독자가 읽는 파일이면, 기계 장치가 바뀌어 마크업만 바뀐 것이 DEV, 글과 수학이 그대로인 손질이 MECHANICAL, 나머지가 CONTENT다. 경로가 아니라 내용으로 가르므로 정리 블록 문법을 통째로 옮기는 마크업 이전에서는 글 .md도 DEV가 된다. 애매할 때의 규칙은 피해가 작은 쪽으로 기운다.
- Unsure between MECHANICAL and CONTENT: choose CONTENT.
- Unsure between DEV and MECHANICAL: choose MECHANICAL. Both are skipped for
lastmod, so the only cost of a wrong DEV is polluting Marvin's work queue.
출력은 <path>\t<LABEL> 줄이어야 한다. 형식이 어긋난 줄이 있거나, 판정이 빠진 파일이 있거나, 입력 diff가 CLASSIFY_DIFF_BUDGET(8만 자)을 넘거나, 호출 자체가 실패하면 classify_changes는 None을 돌려준다. 그러면 autopush는 스테이징을 되돌리고(git reset -q) 아무것도 커밋하지 않은 채 멈춰 알림을 보낸다. 변경분은 워킹트리에 남아 다음 틱을 기다린다. 확신 없이 마커를 잘못 다느니 멈추는 편이 낫다는 게 사용자의 판단이었다. 진짜 수정을 기계적이라 잘못 분류하는 것이 가장 피해야 할 실패다. 입력에서 글은 diff 전체가 가고, 글이 아닌 파일은 1500자 발췌나 --numstat 요약 한 줄로 줄어든다. 그래서 멈추는 것은 글의 diff만으로 한도를 넘을 때다.
헤더의 작성일·수정일 행
글 하나에 날짜가 두 번 뜨던 때가 있었다. 제목 아래에는 테마의 page__meta.html이 작성일과 수정일을 라벨 없이 가운뎃점으로 이어 붙인 한 줄이 있었고, 본문 끝의 footer. 블록은 헤더 eyebrow에 이미 있는 카테고리와 날짜를 다시 보여 줬다. 같은 정보를 화면 위아래에 나눠 둘 이유가 없다고 사용자가 짚었고, 위쪽을 제대로 만들고 아래쪽은 걷어내라고 했다. 그래서 single.html에서 include 한 줄이 page__dates.로 바뀌고 footer 블록이 없어졌다.
page__dates.은 행마다 아이콘, 라벨, 가운뎃점, 날짜를 묶는다. 수정일 행은 이렇다.
{% if page.last_modified_at %}
<span class="page__dates-row">
<i class="material-icons md-14" aria-hidden="true">update</i>
<span class="page__dates-label">{{ site.data.ui-text[lang].date_updated_label | default: "Updated" }}</span>
<span class="page__dates-sep" aria-hidden="true">·</span>
<time class="dt-updated" datetime="{{ page.last_modified_at | date: "%Y-%m-%d" }}">{{ page.last_modified_at | date: date_format }}</time>
</span>
{% endif %}
작성일 행은 같은 모양에 event_available 아이콘과 dt-published를 쓴다. 라벨은 _data/의 date_created_label·date_updated_label(한국어 “작성일”·”수정일”, 영어 “Posted”·”Updated”)이고, lang은 사이트 locale이 아니라 page.url의 앞 세 글자에서 뽑는다. 글 자체의 언어를 따라가야 번역본에서 라벨이 뒤바뀌지 않는다. page.read_time이 참이면 라벨 없는 읽는 시간 행이 세 번째로 붙는데, 워크숍 글은 전부 read_time: false라 보이지 않는다.
.page__dates {
color: $muted-text-color;
font-family: $sans-serif;
font-size: $type-size-6;
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 1.6em; /* 항목 사이 여백 */
row-gap: 0.4em;
.page__dates-row {
display: inline-flex;
align-items: baseline;
gap: 0.32em; /* 아이콘·라벨·중간점·날짜 사이 */
white-space: nowrap;
}
.material-icons {
align-self: center; /* 아이콘 폰트는 baseline이 어긋나므로 행 중앙 정렬 */
margin-right: 0.1em;
}
}
각 행은 줄바꿈 단위다. 좁은 화면에서 flex-wrap은 행을 통째로 다음 줄로 넘기고, white- 때문에 아이콘과 라벨 사이에서 끊지 않는다. Material Icons는 글리프가 baseline 아래로 내려앉게 그려져 있어서 텍스트 baseline에 맞추면 반쯤 가라앉으므로, 아이콘만 align-로 뺐다.
.page__dates에는 page__meta 클래스가 없다. 처음에는 테마의 타이포그래피를 물려받으려고 그 클래스를 얹었는데, 헤더와 본문 사이 간격이 지정한 값을 무시했다. 테마의 .page .page__inner- 규칙이 float과 clear를 써서, 본문이 그 clearance를 따라 올라오며 헤더의 margin-bottom을 지운 것이다. 클래스 하나를 물려받으려다 레이아웃 규칙까지 물려받은 셈이라, 타이포그래피는 위처럼 .page__dates에 직접 적고 “page__meta 클래스를 얹지 말 것”이라는 주의를 SCSS와 include 양쪽 주석에 남겼다.
이 글 헤더의 수정일 행에 찍힌 날짜는 git이 아니라 이 글 프론트매터의 last_modified_at: 2026-에서 왔다. YAML이 그 값을 Date로 읽으므로, :post_read 훅은 Determinator가 아닌 값을 보고 git에 묻지 않은 채 지나간다.
공유하기
Twitter Facebook LinkedIn댓글
- 공개 저장소입니다. 댓글을 지워도 Git 히스토리에는 남습니다.
- 암호와 이메일은 암호화해 Cloudflare KV에만 보관합니다.
아직 댓글이 없습니다.