66# 该脚本是 “最近更新” 页面生成器,输出结构固定为:
77# - H1 标题
88# - Prenote 提示
9+ # - 最近 N 次变更记录(默认 3 次,先进先出)
10+ #
11+ # 每条“变更记录”包含:
12+ # - 记录标题(本次变更总标题)
913# - Summary(生成时间、基准提交、diff 来源、统计)
1014# - Index(文件索引 + +/-)
1115# - 每个文件一个可折叠 diff 区块
2125# - 默认排除 src/sitemap.xml(该文件时间戳噪音大,几乎每次都变)
2226# - 保留 sitemap.txt(文章新增时具有信息价值)
2327#
28+ # 历史保留策略:
29+ # - 默认最多保留 3 条记录(可通过 --history-limit 调整)
30+ # - 新记录始终在最上方
31+ # - 超出数量时淘汰最旧记录(FIFO)
32+ #
2433# 依赖:
2534# - git、bash、sed、awk、cksum、mktemp
2635# ==============================================================================
@@ -46,6 +55,10 @@ FENCE="~~~~~"
4655
4756MODE=" staged"
4857GIT_RANGE=" "
58+ HISTORY_LIMIT=3
59+
60+ ENTRY_START_MARK=" <!-- LAST_UPDATED_ENTRY_START -->"
61+ ENTRY_END_MARK=" <!-- LAST_UPDATED_ENTRY_END -->"
4962
5063# 默认排除 generated 文件自身和 sitemap.xml(仅时间戳噪音)
5164EXCLUDE_FILES=(
@@ -55,19 +68,20 @@ EXCLUDE_FILES=(
5568
5669usage () {
5770 # 打印脚本帮助信息。
58- cat << 'EOF '
71+ cat << 'USAGE '
5972Usage:
60- ./generate-last-updated-md.sh [--staged] [--range <git-range>] [--output <path>]
73+ ./generate-last-updated-md.sh [--staged] [--range <git-range>] [--history-limit <n>] [-- output <path>]
6174
6275Options:
63- --staged Use staged diff (default).
64- --range <range> Use git range diff, e.g. abc123..def456.
65- --output <path> Output markdown file path.
66- -h, --help Show this help.
67- EOF
76+ --staged Use staged diff (default).
77+ --range <range> Use git range diff, e.g. abc123..def456.
78+ --history-limit <n> Keep latest n records (FIFO), default: 3.
79+ --output <path> Output markdown file path.
80+ -h, --help Show this help.
81+ USAGE
6882}
6983
70- # 参数解析:将用户意图映射到 MODE + GIT_RANGE + OUTPUT_FILE。
84+ # 参数解析:将用户意图映射到 MODE + GIT_RANGE + HISTORY_LIMIT + OUTPUT_FILE。
7185while [[ $# -gt 0 ]]; do
7286 case " $1 " in
7387 --staged)
@@ -84,6 +98,14 @@ while [[ $# -gt 0 ]]; do
8498 GIT_RANGE=" $2 "
8599 shift 2
86100 ;;
101+ --history-limit)
102+ if [[ $# -lt 2 ]]; then
103+ echo " Error: --history-limit requires a value." >&2
104+ exit 1
105+ fi
106+ HISTORY_LIMIT=" $2 "
107+ shift 2
108+ ;;
87109 --output)
88110 if [[ $# -lt 2 ]]; then
89111 echo " Error: --output requires a value." >&2
@@ -109,6 +131,12 @@ if [[ "${MODE}" == "range" && -z "${GIT_RANGE}" ]]; then
109131 exit 1
110132fi
111133
134+ # 正则 ^[1-9][0-9]*$:history limit 必须是正整数。
135+ if ! [[ " ${HISTORY_LIMIT} " =~ ^[1-9][0-9]* $ ]]; then
136+ echo " Error: --history-limit must be a positive integer." >&2
137+ exit 1
138+ fi
139+
112140# 防御式检查:防止在错误目录下执行并误写文件。
113141if ! git -C " ${PROOT} " rev-parse --is-inside-work-tree > /dev/null 2>&1 ; then
114142 echo " Error: ${PROOT} is not a git repository." >&2
122150
123151TMP_ROOT=" ${TMPDIR:-/ tmp} "
124152TMP_DIR=" $( mktemp -d " ${TMP_ROOT%/ } /last-updated.XXXXXX" ) "
153+
125154# 无论成功或失败都清理临时目录,避免残留 patch 文件。
126155cleanup () {
127- # 删除用于缓存每个文件 patch 的临时目录 。
156+ # 删除用于缓存每个文件 patch 与历史记录切片的临时目录 。
128157 rm -rf " ${TMP_DIR} "
129158}
130159trap cleanup EXIT
@@ -147,8 +176,10 @@ make_anchor() {
147176 # 规则:
148177 # 1) 先把路径做 slug 化(仅保留字母数字,其他字符压成 -)
149178 # 2) 再拼接 cksum,避免不同路径 slug 冲突
179+ # 3) 再拼接 entry_key,避免不同“历史记录块”之间锚点冲突
150180 local file_path=" $1 "
151181 local index=" $2 "
182+ local entry_key=" $3 "
152183 local slug checksum
153184
154185 # sed 正则说明:
@@ -164,7 +195,7 @@ make_anchor() {
164195 slug=" file-${index} "
165196 fi
166197
167- printf ' f-%s-%s' " ${slug} " " ${checksum} "
198+ printf ' f-%s-%s-%s ' " ${entry_key} " " ${slug} " " ${checksum} "
168199}
169200
170201stat_to_display () {
@@ -205,89 +236,124 @@ collect_numstat() {
205236 printf ' %s\t%s\n' " ${added} " " ${deleted} "
206237}
207238
208- write_header () {
209- # 写入文档固定头部:标题、提示、Summary 统计。
210- local generated_at=" $1 "
211- local base_ref=" $2 "
212- local diff_source=" $3 "
213- local file_count=" $4 "
214- local total_added=" $5 "
215- local total_deleted=" $6 "
216- local binary_count=" $7 "
217-
218- cat > " ${OUTPUT_FILE} " << EOF
239+ write_document_header () {
240+ # 写入文档固定头部:H1 + Prenote。
241+ local target_file=" $1 "
242+ cat > " ${target_file} " << EOF_HEADER
219243# ${TITLE}
220244
221245## Prenote
222246
223247> ${NOTICE_CONTENT}
224248
225- ## Summary
226-
227- - Generated at: \` ${generated_at} \`
228- - Base commit: \` ${base_ref} \`
229- - Diff source: \` ${diff_source} \`
230- - Changed files: \` ${file_count} \`
231- - Total lines: \` +${total_added} / -${total_deleted} \`
232- EOF
249+ EOF_HEADER
250+ }
233251
234- if [[ " ${binary_count} " -gt 0 ]]; then
235- printf -- ' - Binary-like diffs: `%s`\n' " ${binary_count} " >> " ${OUTPUT_FILE} "
236- fi
252+ write_current_entry () {
253+ # 写入“本次变更记录块”,包含标题、Summary、Index 与 diff 详情。
254+ local target_file=" $1 "
255+ local entry_title=" $2 "
256+ local generated_at=" $3 "
257+ local base_ref=" $4 "
258+ local diff_source=" $5 "
259+ local file_count=" $6 "
260+ local total_added=" $7 "
261+ local total_deleted=" $8 "
262+ local binary_count=" $9 "
263+
264+ local i add_display del_display
265+
266+ {
267+ printf ' %s\n' " ${ENTRY_START_MARK} "
268+ printf ' ## %s\n\n' " ${entry_title} "
269+
270+ printf ' ### Summary\n\n'
271+ printf -- ' - Generated at: `%s`\n' " ${generated_at} "
272+ printf -- ' - Base commit: `%s`\n' " ${base_ref} "
273+ printf -- ' - Diff source: `%s`\n' " ${diff_source} "
274+ printf -- ' - Changed files: `%s`\n' " ${file_count} "
275+ printf -- ' - Total lines: `+%s / -%s`\n' " ${total_added} " " ${total_deleted} "
276+ if [[ " ${binary_count} " -gt 0 ]]; then
277+ printf -- ' - Binary-like diffs: `%s`\n' " ${binary_count} "
278+ fi
279+ printf ' \n'
280+
281+ if [[ " ${file_count} " -eq 0 ]]; then
282+ printf ' ### No Changes\n\n'
283+ printf ' No file changes found for the selected diff source.\n\n'
284+ else
285+ printf ' ### Index\n\n'
286+ for (( i = 0 ; i < file_count; i++ )) ; do
287+ printf ' %s. [%s](#%s) `+%s / -%s`\n' \
288+ " $(( i + 1 )) " \
289+ " ${FILES[$i]} " \
290+ " ${ANCHORS[$i]} " \
291+ " $( stat_to_display " ${ADDED_STATS[$i]} " ) " \
292+ " $( stat_to_display " ${DELETED_STATS[$i]} " ) "
293+ done
294+ printf ' \n'
295+
296+ printf ' ### Diffs\n\n'
297+ for (( i = 0 ; i < file_count; i++ )) ; do
298+ add_display=" $( stat_to_display " ${ADDED_STATS[$i]} " ) "
299+ del_display=" $( stat_to_display " ${DELETED_STATS[$i]} " ) "
300+
301+ printf ' <a id="%s"></a>\n' " ${ANCHORS[$i]} "
302+ printf ' #### %s\n\n' " ${FILES[$i]} "
303+ printf ' <details>\n'
304+ printf ' <summary><code>+%s / -%s</code> Click to expand diff</summary>\n\n' " ${add_display} " " ${del_display} "
305+ printf ' %sdiff\n' " ${FENCE} "
306+ cat " ${DIFF_FILES[$i]} "
307+ printf ' \n%s\n\n' " ${FENCE} "
308+ printf ' </details>\n\n'
309+ done
310+ fi
237311
238- printf ' \n' >> " ${OUTPUT_FILE} "
312+ printf ' %s\n\n' " ${ENTRY_END_MARK} "
313+ } > " ${target_file} "
239314}
240315
241- append_index () {
242- # 写入 Index 目录,提供“文件 -> 锚点”跳转。
243- local count=" $1 "
244- local i
245- printf ' ## Index\n\n' >> " ${OUTPUT_FILE} "
246- for (( i = 0 ; i < count; i++ )) ; do
247- printf ' %s. [%s](#%s) `+%s / -%s`\n' \
248- " $(( i + 1 )) " \
249- " ${FILES[$i]} " \
250- " ${ANCHORS[$i]} " \
251- " $( stat_to_display " ${ADDED_STATS[$i]} " ) " \
252- " $( stat_to_display " ${DELETED_STATS[$i]} " ) " \
253- >> " ${OUTPUT_FILE} "
254- done
255- printf ' \n' >> " ${OUTPUT_FILE} "
256- }
316+ extract_old_entries () {
317+ # 从旧版 last-updated.md 中提取历史记录块(按出现顺序:新 -> 旧)。
318+ # 仅识别被 ENTRY_START/ENTRY_END 包裹的块,旧格式(无标记)会被自动忽略。
319+ local source_file=" $1 "
320+ local line in_entry=0 entry_file=" " idx=0
321+
322+ OLD_ENTRY_FILES=()
257323
258- append_no_changes_note () {
259- # 没有匹配到变更文件时写入兜底提示。
260- cat >> " ${OUTPUT_FILE} " << 'EOF '
261- ## No Changes
324+ if [[ ! -f " ${source_file} " ]]; then
325+ return 0
326+ fi
262327
263- No file changes found for the selected diff source.
328+ while IFS= read -r line || [[ -n " ${line} " ]]; do
329+ if [[ " ${line} " == " ${ENTRY_START_MARK} " ]]; then
330+ in_entry=1
331+ entry_file=" ${TMP_DIR} /entry-old-${idx} .md"
332+ : > " ${entry_file} "
333+ printf ' %s\n' " ${line} " >> " ${entry_file} "
334+ continue
335+ fi
264336
265- EOF
337+ if [[ " ${in_entry} " -eq 1 ]]; then
338+ printf ' %s\n' " ${line} " >> " ${entry_file} "
339+ if [[ " ${line} " == " ${ENTRY_END_MARK} " ]]; then
340+ OLD_ENTRY_FILES+=(" ${entry_file} " )
341+ idx=$(( idx + 1 ))
342+ in_entry=0
343+ fi
344+ fi
345+ done < " ${source_file} "
266346}
267347
268- append_file_sections () {
269- # 写入每个文件的正文区块:
270- # - 文件标题
271- # - 可折叠详情(details/summary)
272- # - 原始 diff 内容
273- local count=" $1 "
348+ append_old_entries () {
349+ # 将旧记录按顺序追加到目标文件,最多追加 keep_count 条。
350+ local target_file=" $1 "
351+ local keep_count=" $2 "
274352 local i
275- local add_display del_display
276-
277- for (( i = 0 ; i < count; i++ )) ; do
278- add_display=" $( stat_to_display " ${ADDED_STATS[$i]} " ) "
279- del_display=" $( stat_to_display " ${DELETED_STATS[$i]} " ) "
280-
281- {
282- printf ' <a id="%s"></a>\n' " ${ANCHORS[$i]} "
283- printf ' ## %s\n\n' " ${FILES[$i]} "
284- printf ' <details>\n'
285- printf ' <summary><code>+%s / -%s</code> Click to expand diff</summary>\n\n' " ${add_display} " " ${del_display} "
286- printf ' %sdiff\n' " ${FENCE} "
287- cat " ${DIFF_FILES[$i]} "
288- printf ' \n%s\n\n' " ${FENCE} "
289- printf ' </details>\n\n'
290- } >> " ${OUTPUT_FILE} "
353+
354+ for (( i = 0 ; i < keep_count && i < ${# OLD_ENTRY_FILES[@]} ; i++ )) ; do
355+ cat " ${OLD_ENTRY_FILES[$i]} " >> " ${target_file} "
356+ printf ' \n' >> " ${target_file} "
291357 done
292358}
293359
@@ -303,12 +369,15 @@ else
303369fi
304370
305371GENERATED_AT=" $( date ' +%Y-%m-%d %H:%M:%S %z' ) "
372+ ENTRY_KEY=" $( printf ' %s|%s|%s' " ${GENERATED_AT} " " ${BASE_REF} " " ${DIFF_SOURCE} " | cksum | awk ' {print $1}' ) "
373+ ENTRY_TITLE=" 更新记录(${GENERATED_AT} | ${BASE_REF} )"
306374
307375declare -a FILES
308376declare -a ADDED_STATS
309377declare -a DELETED_STATS
310378declare -a ANCHORS
311379declare -a DIFF_FILES
380+ declare -a OLD_ENTRY_FILES
312381
313382TOTAL_ADDED=0
314383TOTAL_DELETED=0
@@ -350,7 +419,7 @@ while IFS= read -r -d '' changed_file; do
350419 FILES+=(" ${changed_file} " )
351420 ADDED_STATS+=(" ${added} " )
352421 DELETED_STATS+=(" ${deleted} " )
353- ANCHORS+=(" $( make_anchor " ${changed_file} " " ${FILE_COUNT} " ) " )
422+ ANCHORS+=(" $( make_anchor " ${changed_file} " " ${FILE_COUNT} " " ${ENTRY_KEY} " ) " )
354423 DIFF_FILES+=(" ${local_diff_file} " )
355424
356425 file_has_binary_stat=false
@@ -376,13 +445,26 @@ while IFS= read -r -d '' changed_file; do
376445 FILE_COUNT=$(( FILE_COUNT + 1 ))
377446done < <( " ${FILE_SOURCE_CMD[@]} " )
378447
379- # 先写头部再写正文,No Changes 场景也能保留固定框架与统计信息。
380- write_header " ${GENERATED_AT} " " ${BASE_REF} " " ${DIFF_SOURCE} " " ${FILE_COUNT} " " ${TOTAL_ADDED} " " ${TOTAL_DELETED} " " ${BINARY_COUNT} "
448+ CURRENT_ENTRY_FILE=" ${TMP_DIR} /entry-current.md"
381449
382- if [[ " ${FILE_COUNT} " -eq 0 ]]; then
383- append_no_changes_note
384- exit 0
385- fi
450+ # 先生成本次记录,再抽取旧记录,最后按“新 + 旧(最多 N-1 条)”重建文件。
451+ write_current_entry \
452+ " ${CURRENT_ENTRY_FILE} " \
453+ " ${ENTRY_TITLE} " \
454+ " ${GENERATED_AT} " \
455+ " ${BASE_REF} " \
456+ " ${DIFF_SOURCE} " \
457+ " ${FILE_COUNT} " \
458+ " ${TOTAL_ADDED} " \
459+ " ${TOTAL_DELETED} " \
460+ " ${BINARY_COUNT} "
386461
387- append_index " ${FILE_COUNT} "
388- append_file_sections " ${FILE_COUNT} "
462+ extract_old_entries " ${OUTPUT_FILE} "
463+
464+ write_document_header " ${OUTPUT_FILE} "
465+ cat " ${CURRENT_ENTRY_FILE} " >> " ${OUTPUT_FILE} "
466+ printf ' \n' >> " ${OUTPUT_FILE} "
467+
468+ if (( HISTORY_LIMIT > 1 )) ; then
469+ append_old_entries " ${OUTPUT_FILE} " " $(( HISTORY_LIMIT - 1 )) "
470+ fi
0 commit comments