撤销、更正与恢复
本节目标
你会先判断变化位于工作区、暂存区还是提交历史,再选择影响范围最小的恢复动作。最后,你会更正一笔尚未共享的本地提交,并得到完整的八提交历史。
开始前状态
本节从上一节的 C7 开始:当前分支是 main,最新提交主题是 merge: resolve troubleshooting guide,工作区和暂存区干净,只存在 refs/heads/main,没有 remote、标签或未完成操作。
必要原理
先判断“变化在哪里”和“提交是否共享”,再决定动作:
| 变化位置 | 是否共享 | 本节选择 |
|---|---|---|
| 工作区,尚未暂存 | 否 | 用 git restore 丢弃指定课程文件的工作区变化 |
| 暂存区,尚未提交 | 否 | 先用 git restore --staged 取消暂存,再决定是否保留工作区变化 |
| 最新本地提交 | 否 | 复核提交身份后用 git commit --amend 更正 |
| 已共享提交 | 是或不确定 | 不改写;停止并与协作者确认,用新提交表达更正 |
git restore --staged 只把指定路径从暂存区退回工作区,不会替你删除工作区内容。随后执行的 git restore 才会把该路径恢复为 HEAD 中的版本。
动手完成
迁移只接受精确 C7。它先在同一个文件中制造 staged 与 unstaged 两层变化并恢复;随后创建一笔不完整的本地提交,复核它仍是课程刚创建且没有 remote 的提交,最后才 amend。任何 ref、index、配置或文件 identity 被外来状态替换时,迁移保留现场并失败。
课程文件先以非截断方式打开并绑定目标 FD。Linux 通过已经绑定的 /dev/fd/7 截断;macOS 的 /dev/fd 不接受带截断标志的重开,因此 Darwin 分支只在确认 /usr/bin/perl 可执行后,对继承的 FD 7 调用 ftruncate,绝不向适配器传入 pathname。适配器缺失或平台未知时会在写入前失败。
本节的固定 writer 会先通过只读 pipe 计算 SHA-256,并与课程权威值一致后才打开目标;随后 writer 直接写入已经绑定的 FD 7,不创建磁盘临时文件。若第二个文件写入失败或延迟信号到达,EXIT 回滚仍只使用同一套 FD-bound 原语恢复 C7。
advance_git_undo_recovery() (
set -euo pipefail
devlab_input="${DEVLAB_DIR:-}"
test -n "$devlab_input" || { printf 'DEVLAB_DIR 必须是明确的练习目录。\n' >&2; exit 1; }
test -d "$devlab_input" && test ! -L "$devlab_input" || exit 1
devlab_logical="$(cd -- "$devlab_input" && pwd -L)" || exit 1
devlab_dir="$(cd -- "$devlab_input" && pwd -P)" || exit 1
test "$devlab_logical" = "$devlab_dir" && test "$devlab_dir" != / || exit 1
test -d "$devlab_dir/.git" && test ! -L "$devlab_dir/.git" || exit 1
notes_target="$devlab_dir/notes/git-notes.md"
readme_target="$devlab_dir/README.md"
test -d "$devlab_dir/notes" && test ! -L "$devlab_dir/notes" || exit 1
test -f "$notes_target" && test ! -L "$notes_target" || exit 1
test -f "$readme_target" && test ! -L "$readme_target" || exit 1
git_bin="$(PATH=/usr/bin:/bin command -v git)" || exit 1
read_local_config() {
/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C HOME="$devlab_dir" \
GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
GIT_ATTR_NOSYSTEM=1 GIT_PAGER=cat GIT_OPTIONAL_LOCKS=0 \
"$git_bin" -C "$devlab_dir" config --local --no-includes "$@"
}
run_git() {
/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C HOME="$devlab_dir" \
GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
GIT_ATTR_NOSYSTEM=1 GIT_PAGER=cat GIT_OPTIONAL_LOCKS=0 \
"$git_bin" -c core.hooksPath=/dev/null -c core.fsmonitor=false \
-c core.worktree="$devlab_dir" -C "$devlab_dir" "$@"
}
file_hash() {
local output
if command -v sha256sum >/dev/null 2>&1; then output="$(sha256sum -- "$1")" || return 1
elif command -v shasum >/dev/null 2>&1; then output="$(shasum -a 256 -- "$1")" || return 1
else return 1
fi
printf '%s' "${output%% *}"
}
file_identity() {
local identity
if identity="$(LC_ALL=C stat -L -c '%d:%i' "$1" 2>/dev/null)"; then :
else identity="$(LC_ALL=C stat -L -f '%d:%i' "$1")" || return 1
fi
printf '%s' "$identity"
}
file_inode() {
local inode
if inode="$(LC_ALL=C stat -L -c '%i' "$1" 2>/dev/null)"; then :
else inode="$(LC_ALL=C stat -L -f '%i' "$1")" || return 1
fi
printf '%s' "$inode"
}
file_link_count() {
local links
if links="$(LC_ALL=C stat -L -c '%h' "$1" 2>/dev/null)"; then :
else links="$(LC_ALL=C stat -L -f '%l' "$1")" || return 1
fi
printf '%s' "$links"
}
fd_matches_course_identity() {
local fd_path="$1" target="$2" expected_identity="$3" fd_inode path_inode fd_links path_links path_identity
fd_inode="$(file_inode "$fd_path")" || return 1
path_inode="$(file_inode "$target")" || return 1
fd_links="$(file_link_count "$fd_path")" || return 1
path_links="$(file_link_count "$target")" || return 1
path_identity="$(file_identity "$target")" || return 1
test "$fd_inode" = "$path_inode" && test "$fd_links" -gt 0 && test "$fd_links" = "$path_links" \
&& test "$path_identity" = "$expected_identity"
}
open_course_rewrite_fd() {
local target="$1" expected_identity="$2" expected_hash="${3:-}" actual_hash
test -f "$target" && test ! -L "$target" || return 1
exec 7<> "$target" || return 1
fd_matches_course_identity /dev/fd/7 "$target" "$expected_identity" || { exec 7>&-; return 1; }
if test -n "$expected_hash"; then
actual_hash="$(file_hash "$target")" || { exec 7>&-; return 1; }
test "$actual_hash" = "$expected_hash" || { exec 7>&-; return 1; }
fd_matches_course_identity /dev/fd/7 "$target" "$expected_identity" || { exec 7>&-; return 1; }
fi
}
open_course_append_fd() {
local target="$1" expected_identity="$2" expected_hash="$3" actual_hash
test -f "$target" && test ! -L "$target" || return 1
exec 7>> "$target" || return 1
fd_matches_course_identity /dev/fd/7 "$target" "$expected_identity" || { exec 7>&-; return 1; }
actual_hash="$(file_hash "$target")" || { exec 7>&-; return 1; }
test "$actual_hash" = "$expected_hash" || { exec 7>&-; return 1; }
fd_matches_course_identity /dev/fd/7 "$target" "$expected_identity" || { exec 7>&-; return 1; }
}
truncate_bound_course_fd() {
local platform="$1"
case "$platform" in
Darwin)
test -x /usr/bin/perl || return 1
/usr/bin/perl -e 'truncate(STDOUT, 0) or exit 1' >&7
;;
Linux) : > /dev/fd/7 ;;
*) return 1 ;;
esac
}
begin_bound_course_write() { :; }
begin_bound_course_rollback_write() { :; }
finish_course_write_fd() {
local target="$1" expected_identity="$2" expected_hash="$3" hash_variable="${4:-}" actual_hash
fd_matches_course_identity /dev/fd/7 "$target" "$expected_identity" || { exec 7>&-; return 1; }
exec 7>&-
actual_hash="$(file_hash "$target")" || return 1
if test -n "$expected_hash"; then test "$actual_hash" = "$expected_hash" || return 1; fi
test "$(file_identity "$target")" = "$expected_identity" || return 1
if test -n "$hash_variable"; then printf -v "$hash_variable" '%s' "$actual_hash"; fi
}
capture_failed_course_write_fd() {
local target="$1" expected_identity="$2" hash_variable="$3" actual_hash
fd_matches_course_identity /dev/fd/7 "$target" "$expected_identity" || { exec 7>&-; return 1; }
exec 7>&-
actual_hash="$(file_hash "$target")" || return 1
test "$(file_identity "$target")" = "$expected_identity" || return 1
printf -v "$hash_variable" '%s' "$actual_hash"
}
course_writer_hash() {
local writer="$1" output
if command -v sha256sum >/dev/null 2>&1; then output="$("$writer" | sha256sum)" || return 1
elif command -v shasum >/dev/null 2>&1; then output="$("$writer" | shasum -a 256)" || return 1
else return 1
fi
printf '%s' "${output%% *}"
}
preflight_course_writer() {
local writer="$1" expected_hash="$2" actual_hash
actual_hash="$(course_writer_hash "$writer")" || return 1
test "$actual_hash" = "$expected_hash"
}
begin_preflighted_course_write_fd() {
local target="$1" expected_identity="$2" current_hash="$3"
open_course_rewrite_fd "$target" "$expected_identity" "$current_hash" || return 1
begin_bound_course_write
truncate_bound_course_fd "$(/usr/bin/uname -s)" || { exec 7>&-; return 1; }
}
blob_hash() {
local output
if command -v sha256sum >/dev/null 2>&1; then output="$(run_git cat-file blob "$1" | sha256sum)" || return 1
elif command -v shasum >/dev/null 2>&1; then output="$(run_git cat-file blob "$1" | shasum -a 256)" || return 1
else return 1
fi
printf '%s' "${output%% *}"
}
verify_local_config_contract() {
local config_names config_name optional_key
config_names="$(read_local_config --name-only --list)" || return 1
while IFS= read -r config_name; do
case "$config_name" in
core.repositoryformatversion|core.filemode|core.bare|core.logallrefupdates|core.ignorecase|core.precomposeunicode|core.hookspath|core.fsmonitor|user.name|user.email) ;;
*) printf '仓库配置包含课程未允许的键:%s\n' "$config_name" >&2; return 1 ;;
esac
done <<< "$config_names"
test "$(read_local_config --get-all core.repositoryformatversion)" = 0 || return 1
case "$(read_local_config --get-all core.filemode)" in true|false) ;; *) return 1 ;; esac
test "$(read_local_config --get-all core.bare)" = false || return 1
test "$(read_local_config --get-all core.logallrefupdates)" = true || return 1
test "$(read_local_config --get-all core.hooksPath)" = /dev/null || return 1
test "$(read_local_config --get-all core.fsmonitor)" = false || return 1
test "$(read_local_config --get-all user.name)" = 'Dev Lab' || return 1
test "$(read_local_config --get-all user.email)" = 'dev-lab@example.invalid' || return 1
for optional_key in core.ignorecase core.precomposeunicode; do
case "$(read_local_config --get-all "$optional_key" 2>/dev/null || true)" in ''|true|false) ;; *) return 1 ;; esac
done
}
verify_git_tree_manifest() {
local treeish="$1" readme_sha="$2" notes_sha="$3" expected_paths actual_manifest metadata relative_path mode type blob_oid expected_oid seen_paths=''
expected_paths='.gitignore
README.md
config/bashrc.dev-lab
config/env.dev-lab
data/access.log
data/health-events.log'
test "$notes_sha" = absent || expected_paths="${expected_paths}"$'\n''notes/git-notes.md'
expected_paths="${expected_paths}"$'\n''notes/linux-notes.md
notes/shell-notes.md
output/health-report.txt
output/summary.txt
scripts/health-check.sh
scripts/permission-demo.sh
scripts/report.sh'
actual_manifest="$(run_git ls-tree -r "$treeish")" || return 1
while IFS=$'\t' read -r metadata relative_path; do
set -- $metadata
test "$#" -eq 3 || return 1
mode="$1"; type="$2"; blob_oid="$3"
test "$type" = blob || return 1
case "$relative_path" in
.gitignore) expected_oid=ff6f87d4c48b221958e3f56a137fc3ff63be1fd5; test "$mode" = 100644 ;;
README.md) expected_oid="$blob_oid"; test "$mode" = 100644 && test "$(blob_hash "$blob_oid")" = "$readme_sha" ;;
config/bashrc.dev-lab) expected_oid=e72213d91e299fe7783a31c4e95fdd54efb07fae; test "$mode" = 100644 ;;
config/env.dev-lab) expected_oid=53b6a1452902b3823ea78d96540d75f30c07d8a6; test "$mode" = 100644 ;;
data/access.log) expected_oid=2818d31895effbe22063a99a0c4da38e75e1f5b6; test "$mode" = 100644 ;;
data/health-events.log) expected_oid=61360929cb9cab8764ec5fc8ae2c3225385098d9; test "$mode" = 100644 ;;
notes/git-notes.md) expected_oid="$blob_oid"; test "$notes_sha" != absent && test "$mode" = 100644 && test "$(blob_hash "$blob_oid")" = "$notes_sha" ;;
notes/linux-notes.md) expected_oid=9caebd63116c9ab87240e2c3324768533ee1cc6d; test "$mode" = 100644 ;;
notes/shell-notes.md) expected_oid=cc013334ea8e5bce8d419c0ea39cac1d348ebcdc; test "$mode" = 100644 ;;
output/health-report.txt) expected_oid=22f76e9e3cb4f9df91c98a918cb560a2e2e4461a; test "$mode" = 100644 ;;
output/summary.txt) expected_oid=26de3df8058ee2a0582284c451905026b8ee7a8b; test "$mode" = 100644 ;;
scripts/health-check.sh) expected_oid=60607552089d0082f930ba243df23802b3627ba0; test "$mode" = 100755 ;;
scripts/permission-demo.sh) expected_oid=87e6bf27dd8f4c2029951ef72bb800678d23b640; test "$mode" = 100755 ;;
scripts/report.sh) expected_oid=ae08bbe9f23450b9676b2c87a70451525bd9851f; test "$mode" = 100755 ;;
*) return 1 ;;
esac || return 1
test "$blob_oid" = "$expected_oid" || return 1
seen_paths="${seen_paths}${seen_paths:+$'\n'}${relative_path}"
done <<< "$actual_manifest"
test "$seen_paths" = "$expected_paths" || return 1
}
write_c7_notes() {
printf '%s\n' '# Git 练习记录' '' '## 工作区模型' '' \
'- 工作区保存当前文件。' '- 暂存区保存下一次提交的候选快照。' '- HEAD 指向当前分支的提交。' '' \
'## 分支工作流' '' '- 在功能分支完成独立修改。' '- 合并前先确认当前分支和工作区状态。' '' \
'## 排错顺序' '' '- 先查看状态与差异。' '- 再确认当前分支和目标。' '' \
'## 安全恢复' '' '课程将在恢复练习中补充这里。'
}
write_c8_notes() {
printf '%s\n' '# Git 练习记录' '' '## 工作区模型' '' \
'- 工作区保存当前文件。' '- 暂存区保存下一次提交的候选快照。' '- HEAD 指向当前分支的提交。' '' \
'## 分支工作流' '' '- 在功能分支完成独立修改。' '- 合并前先确认当前分支和工作区状态。' '' \
'## 排错顺序' '' '- 先查看状态与差异。' '- 再确认当前分支和目标。' '' \
'## 安全恢复' '' '- 先判断修改是否暂存、是否提交、是否共享。' '- 只更正尚未共享的本地提交。'
}
write_c7_readme() {
printf '%s\n' '# dev-lab Linux checkpoint' '' \
'这是 Linux 基础完成后的权威参考状态。它保留固定样例日志、确定性报告和隔离练习配置;真实系统观察不会写入这个规范快照。' '' \
'请在此目录的父目录运行 `check-linux.sh`。`scripts/health-check.sh` 只在完整输入通过验证后生成 `output/health-report.txt`;checker 只读,不会 source 配置,也不会执行 checkpoint 脚本。'
}
write_draft_readme() {
printf '%s\n' '# dev-lab Git local checkpoint' '' \
'这是 Git 本地能力完成后的权威工作树。它保留 Linux checkpoint 的固定文件,并新增本地 Git 学习记录;`.git/` 历史不存入站点源码。'
}
write_c8_readme() {
printf '%s\n' '# dev-lab Git local checkpoint' '' \
'这是 Git 本地能力完成后的权威工作树。它保留 Linux checkpoint 的固定文件,并新增本地 Git 学习记录;`.git/` 历史不存入站点源码。' '' \
'请使用 `create-git-local.sh` 从规范 Linux checkpoint 重建参考仓库,或在练习目录父级运行 `check-git-local.sh ./dev-lab` 检查自己的连续历史。两个脚本都不读取全局 Git 身份,不添加远程仓库,也不访问网络。'
}
verify_no_git_in_progress_state() {
local state_path
for state_path in MERGE_HEAD REBASE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG sequencer; do
test ! -e "$devlab_dir/.git/$state_path" && test ! -L "$devlab_dir/.git/$state_path" || return 1
done
}
test -f "$devlab_dir/.git/config" && test ! -L "$devlab_dir/.git/config" || exit 1
test -f "$devlab_dir/.git/index" && test ! -L "$devlab_dir/.git/index" || exit 1
config_identity="$(file_identity "$devlab_dir/.git/config")" || exit 1
config_hash="$(file_hash "$devlab_dir/.git/config")" || exit 1
index_identity="$(file_identity "$devlab_dir/.git/index")" || exit 1
index_hash="$(file_hash "$devlab_dir/.git/index")" || exit 1
verify_local_config_contract || exit 1
test "$(file_identity "$devlab_dir/.git/config")" = "$config_identity" || exit 1
test "$(file_hash "$devlab_dir/.git/config")" = "$config_hash" || exit 1
test "$(run_git rev-parse --show-toplevel)" = "$devlab_dir" || exit 1
test "$(run_git branch --show-current)" = main || exit 1
test "$(run_git for-each-ref --format='%(refname)' refs)" = refs/heads/main || exit 1
test -z "$(run_git remote)" || exit 1
test -z "$(run_git status --porcelain=v1 --untracked-files=all)" || exit 1
verify_no_git_in_progress_state || exit 1
c7_head="$(run_git rev-parse HEAD)" || exit 1
c5_head="$(run_git rev-parse "$c7_head^1")" || exit 1
c6_head="$(run_git rev-parse "$c7_head^2")" || exit 1
c4_head="$(run_git rev-parse "$c5_head^")" || exit 1
c2_head="$(run_git rev-parse "$c4_head^1")" || exit 1
c3_head="$(run_git rev-parse "$c4_head^2")" || exit 1
c1_head="$(run_git rev-parse "$c2_head^")" || exit 1
test "$(run_git rev-parse "$c6_head^")" = "$c4_head" || exit 1
test "$(run_git rev-parse "$c3_head^")" = "$c2_head" || exit 1
test "$(run_git rev-list --parents -n 1 "$c1_head")" = "$c1_head" || exit 1
test "$(run_git log -1 --format=%s "$c1_head")" = 'chore: establish dev-lab baseline' || exit 1
test "$(run_git log -1 --format=%s "$c2_head")" = 'docs: add git practice journal' || exit 1
test "$(run_git log -1 --format=%s "$c3_head")" = 'docs: record branch workflow' || exit 1
test "$(run_git log -1 --format=%s "$c4_head")" = 'merge: integrate git workflow' || exit 1
test "$(run_git log -1 --format=%s "$c5_head")" = 'docs: record main troubleshooting step' || exit 1
test "$(run_git log -1 --format=%s "$c6_head")" = 'docs: record branch troubleshooting step' || exit 1
test "$(run_git log -1 --format=%s "$c7_head")" = 'merge: resolve troubleshooting guide' || exit 1
test "$(run_git rev-list --count "$c7_head")" = 7 || exit 1
verify_git_tree_manifest "$c1_head" f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 absent || exit 1
verify_git_tree_manifest "$c2_head" f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || exit 1
verify_git_tree_manifest "$c3_head" f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || exit 1
verify_git_tree_manifest "$c4_head" f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || exit 1
verify_git_tree_manifest "$c5_head" f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 91407f0b23c8f27c9baee81d07d736673c2ff49d661379e63caa0c1479cd5860 || exit 1
verify_git_tree_manifest "$c6_head" f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 f0aa82ddc8dad5b9f1072fc48f2b376385680a214659cb83cc596bfe03583022 || exit 1
verify_git_tree_manifest "$c7_head" f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 || exit 1
test "$(file_hash "$readme_target")" = f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 || exit 1
test "$(file_hash "$notes_target")" = 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 || exit 1
test "$(run_git write-tree)" = "$(run_git rev-parse "$c7_head^{tree}")" || exit 1
devlab_parent="${devlab_dir%/*}"; test -n "$devlab_parent" || devlab_parent=/
devlab_parent_identity="$(file_identity "$devlab_parent")" || exit 1
devlab_identity="$(file_identity "$devlab_dir")" || exit 1
git_identity="$(file_identity "$devlab_dir/.git")" || exit 1
notes_dir_identity="$(file_identity "$devlab_dir/notes")" || exit 1
notes_identity="$(file_identity "$notes_target")" || exit 1
readme_identity="$(file_identity "$readme_target")" || exit 1
owned_notes_hash=42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677
owned_readme_hash=f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5
incomplete_head=''; c8_head=''; git05_handoff=0; pending_signal=0
bind_owned_index() {
local expected_readme="$1" expected_notes="$2" observed_identity observed_hash index_tree
test -f "$devlab_dir/.git/index" && test ! -L "$devlab_dir/.git/index" || return 1
index_tree="$(run_git write-tree)" || return 1
observed_identity="$(file_identity "$devlab_dir/.git/index")" || return 1
observed_hash="$(file_hash "$devlab_dir/.git/index")" || return 1
verify_git_tree_manifest "$index_tree" "$expected_readme" "$expected_notes" || return 1
test "$(file_identity "$devlab_dir/.git/index")" = "$observed_identity" || return 1
index_identity="$observed_identity"
index_hash="$observed_hash"
}
bind_owned_notes() {
local expected_hash="$1" observed_identity
test -f "$notes_target" && test ! -L "$notes_target" || return 1
observed_identity="$(file_identity "$notes_target")" || return 1
test "$(file_hash "$notes_target")" = "$expected_hash" || return 1
test "$(file_identity "$notes_target")" = "$observed_identity" || return 1
notes_identity="$observed_identity"
owned_notes_hash="$expected_hash"
}
bind_owned_readme() {
local expected_hash="$1" observed_identity
test -f "$readme_target" && test ! -L "$readme_target" || return 1
observed_identity="$(file_identity "$readme_target")" || return 1
test "$(file_hash "$readme_target")" = "$expected_hash" || return 1
test "$(file_identity "$readme_target")" = "$observed_identity" || return 1
readme_identity="$observed_identity"
owned_readme_hash="$expected_hash"
}
verify_owned_resources() {
test "$(file_identity "$devlab_parent")" = "$devlab_parent_identity" || return 1
test "$(file_identity "$devlab_dir")" = "$devlab_identity" || return 1
test "$(file_identity "$devlab_dir/.git")" = "$git_identity" || return 1
test -f "$devlab_dir/.git/config" && test ! -L "$devlab_dir/.git/config" || return 1
test "$(file_identity "$devlab_dir/.git/config")" = "$config_identity" || return 1
test "$(file_hash "$devlab_dir/.git/config")" = "$config_hash" || return 1
verify_local_config_contract || return 1
test -f "$devlab_dir/.git/index" && test ! -L "$devlab_dir/.git/index" || return 1
test "$(file_identity "$devlab_dir/.git/index")" = "$index_identity" || return 1
test "$(file_hash "$devlab_dir/.git/index")" = "$index_hash" || return 1
test "$(file_identity "$devlab_dir/notes")" = "$notes_dir_identity" || return 1
test -f "$notes_target" && test ! -L "$notes_target" || return 1
test "$(file_identity "$notes_target")" = "$notes_identity" || return 1
test "$(file_hash "$notes_target")" = "$owned_notes_hash" || return 1
test -f "$readme_target" && test ! -L "$readme_target" || return 1
test "$(file_identity "$readme_target")" = "$readme_identity" || return 1
test "$(file_hash "$readme_target")" = "$owned_readme_hash" || return 1
}
restore_exact_c7() {
local current_main
test "$git05_handoff" -eq 0 || return 1
verify_owned_resources || return 1
test "$(run_git branch --show-current)" = main || return 1
test "$(run_git for-each-ref --format='%(refname)' refs)" = refs/heads/main || return 1
current_main="$(run_git rev-parse refs/heads/main)" || return 1
if test "$current_main" = "$c7_head"; then :
elif test -n "$incomplete_head" && test "$current_main" = "$incomplete_head"; then
run_git update-ref refs/heads/main "$c7_head" "$incomplete_head" || return 1
elif test -n "$c8_head" && test "$current_main" = "$c8_head"; then
run_git update-ref refs/heads/main "$c7_head" "$c8_head" || return 1
else return 1
fi
run_git read-tree "$c7_head^{tree}" || return 1
bind_owned_index f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 || return 1
open_course_rewrite_fd "$readme_target" "$readme_identity" "$owned_readme_hash" || return 1
begin_bound_course_rollback_write
truncate_bound_course_fd "$(/usr/bin/uname -s)" || { exec 7>&-; return 1; }
write_c7_readme >&7 || { exec 7>&-; return 1; }
finish_course_write_fd "$readme_target" "$readme_identity" f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 owned_readme_hash || return 1
open_course_rewrite_fd "$notes_target" "$notes_identity" "$owned_notes_hash" || return 1
begin_bound_course_rollback_write
truncate_bound_course_fd "$(/usr/bin/uname -s)" || { exec 7>&-; return 1; }
write_c7_notes >&7 || { exec 7>&-; return 1; }
finish_course_write_fd "$notes_target" "$notes_identity" 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 owned_notes_hash || return 1
test -z "$(run_git status --porcelain=v1 --untracked-files=all)" || return 1
}
cleanup_git05_on_exit() {
local transition_status="$?"
trap - EXIT HUP INT TERM
restore_exact_c7 || transition_status=1
exit "$transition_status"
}
arm_git05_signals() { trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM; }
defer_git05_signals() {
trap 'if test "$pending_signal" -eq 0; then pending_signal=129; fi' HUP
trap 'if test "$pending_signal" -eq 0; then pending_signal=130; fi' INT
trap 'if test "$pending_signal" -eq 0; then pending_signal=143; fi' TERM
}
finish_git05_critical_section() {
local status="$pending_signal"
pending_signal=0
arm_git05_signals
test "$status" -eq 0 || exit "$status"
}
record_combined_restore_state() { :; }
record_restored_c7_state() { :; }
record_before_amend_oid() { :; }
record_after_amend_oid() { :; }
commit_output_oid() {
local output="$1" expected_subject="$2" first_line remainder oid
first_line="${output%%$'\n'*}"
remainder="${first_line#\[main }"
test "$remainder" != "$first_line" || return 1
oid="${remainder%%]*}"
test "$oid" != "$remainder" || return 1
case "$oid" in ''|*[!0-9a-f]*) return 1 ;; esac
test "${#oid}" -eq 40 || return 1
test "${remainder#"$oid] "}" = "$expected_subject" || return 1
printf '%s' "$oid"
}
begin_draft_readme_write() { verify_owned_resources; }
begin_final_readme_write() { verify_owned_resources; }
begin_final_notes_write() { verify_owned_resources; }
write_final_git_local_files() {
preflight_course_writer write_c8_readme f7afcc991b7f17de1d3b705733e94d0ceb1ede0d614e9fba7435f89390ea85ec || return 1
begin_final_readme_write || return 1
begin_preflighted_course_write_fd "$readme_target" "$readme_identity" "$owned_readme_hash" || return 1
if write_c8_readme >&7; then
finish_course_write_fd "$readme_target" "$readme_identity" f7afcc991b7f17de1d3b705733e94d0ceb1ede0d614e9fba7435f89390ea85ec owned_readme_hash || return 1
else
capture_failed_course_write_fd "$readme_target" "$readme_identity" owned_readme_hash || return 1
return 1
fi
preflight_course_writer write_c8_notes ce1ca9eabafe913d81160cb7de9871f4dc9647c1568262ffe2ed353630a6a79c || return 1
begin_final_notes_write || return 1
begin_preflighted_course_write_fd "$notes_target" "$notes_identity" "$owned_notes_hash" || return 1
if write_c8_notes >&7; then
finish_course_write_fd "$notes_target" "$notes_identity" ce1ca9eabafe913d81160cb7de9871f4dc9647c1568262ffe2ed353630a6a79c owned_notes_hash || return 1
else
capture_failed_course_write_fd "$notes_target" "$notes_identity" owned_notes_hash || return 1
return 1
fi
}
verify_incomplete_commit_candidate() {
local candidate="$1" parents
test "$(run_git rev-parse HEAD)" = "$candidate" || return 1
test "$(run_git rev-parse refs/heads/main)" = "$candidate" || return 1
test "$(run_git log -1 --format=%s "$candidate")" = 'docs: complete local git note' || return 1
parents="$(run_git rev-list --parents -n 1 "$candidate")" || return 1
set -- $parents
test "$#" -eq 2 && test "$2" = "$c7_head" || return 1
test "$(run_git rev-list --count "$candidate")" = 8 || return 1
verify_git_tree_manifest "$candidate" 80c06e152d3570fd53bbaf16adc4ba091268dd33a0b6fbb3993df554b9e0c819 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 || return 1
test "$(run_git diff-tree --no-commit-id --name-only -r "$c7_head" "$candidate")" = README.md || return 1
test "$(run_git rev-parse refs/heads/main)" = "$candidate" || return 1
}
begin_owned_amend() {
verify_owned_resources || return 1
test -n "$incomplete_head" || return 1
test "$(run_git rev-parse HEAD)" = "$incomplete_head" || return 1
test "$(run_git rev-parse refs/heads/main)" = "$incomplete_head" || return 1
test "$(run_git log -1 --format=%s HEAD)" = 'docs: complete local git note' || return 1
test "$(run_git rev-parse HEAD^)" = "$c7_head" || return 1
test "$(run_git for-each-ref --format='%(refname)' refs)" = refs/heads/main || return 1
test -z "$(run_git remote)" || return 1
}
verify_c8_candidate() {
local candidate="$1" parents changed_paths
test "$candidate" != "$incomplete_head" || return 1
test "$(run_git rev-parse HEAD)" = "$candidate" || return 1
test "$(run_git rev-parse refs/heads/main)" = "$candidate" || return 1
test "$(run_git log -1 --format=%s "$candidate")" = 'docs: complete local git notes' || return 1
parents="$(run_git rev-list --parents -n 1 "$candidate")" || return 1
set -- $parents
test "$#" -eq 2 && test "$2" = "$c7_head" || return 1
test "$(run_git rev-list --count "$candidate")" = 8 || return 1
verify_git_tree_manifest "$candidate" f7afcc991b7f17de1d3b705733e94d0ceb1ede0d614e9fba7435f89390ea85ec ce1ca9eabafe913d81160cb7de9871f4dc9647c1568262ffe2ed353630a6a79c || return 1
changed_paths="$(run_git diff-tree --no-commit-id --name-only -r "$c7_head" "$candidate")" || return 1
test "$changed_paths" = $'README.md\nnotes/git-notes.md' || return 1
test "$(run_git rev-parse refs/heads/main)" = "$candidate" || return 1
}
verify_git05_handoff() {
verify_owned_resources || return 1
verify_c8_candidate "$c8_head" || return 1
test "$(run_git branch --show-current)" = main || return 1
test "$(run_git for-each-ref --format='%(refname)' refs)" = refs/heads/main || return 1
test -z "$(run_git remote)" || return 1
test -z "$(run_git status --porcelain=v1 --untracked-files=all)" || return 1
verify_no_git_in_progress_state || return 1
test "$(file_hash "$readme_target")" = f7afcc991b7f17de1d3b705733e94d0ceb1ede0d614e9fba7435f89390ea85ec || return 1
test "$(file_hash "$notes_target")" = ce1ca9eabafe913d81160cb7de9871f4dc9647c1568262ffe2ed353630a6a79c || return 1
}
begin_git05_handoff() { trap '' HUP INT TERM; git05_handoff=1; }
trap cleanup_git05_on_exit EXIT
arm_git05_signals
defer_git05_signals
open_course_append_fd "$notes_target" "$notes_identity" "$owned_notes_hash" || exit 1
printf '%s\n' '- 临时暂存观察。' >&7
finish_course_write_fd "$notes_target" "$notes_identity" '' owned_notes_hash || exit 1
finish_git05_critical_section
defer_git05_signals
run_git add -- notes/git-notes.md
staged_notes_oid="$(run_git rev-parse :notes/git-notes.md)" || exit 1
staged_notes_hash="$(blob_hash "$staged_notes_oid")" || exit 1
bind_owned_index f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 "$staged_notes_hash" || exit 1
finish_git05_critical_section
defer_git05_signals
open_course_append_fd "$notes_target" "$notes_identity" "$owned_notes_hash" || exit 1
printf '%s\n' '- 临时工作区观察。' >&7
finish_course_write_fd "$notes_target" "$notes_identity" '' owned_notes_hash || exit 1
finish_git05_critical_section
test "$(run_git status --short -- notes/git-notes.md)" = 'MM notes/git-notes.md' || exit 1
record_combined_restore_state
run_git diff --staged -- notes/git-notes.md
run_git diff -- notes/git-notes.md
defer_git05_signals
run_git restore --staged -- notes/git-notes.md
bind_owned_index f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 || exit 1
finish_git05_critical_section
defer_git05_signals
run_git restore --source=HEAD --worktree -- notes/git-notes.md
bind_owned_notes 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 || exit 1
finish_git05_critical_section
test -z "$(run_git status --porcelain=v1 --untracked-files=all)" || exit 1
test "$(run_git hash-object "$notes_target")" = "$(run_git rev-parse "$c7_head:notes/git-notes.md")" || exit 1
record_restored_c7_state
defer_git05_signals
preflight_course_writer write_draft_readme 80c06e152d3570fd53bbaf16adc4ba091268dd33a0b6fbb3993df554b9e0c819 || exit 1
finish_git05_critical_section
begin_draft_readme_write || exit 1
defer_git05_signals
begin_preflighted_course_write_fd "$readme_target" "$readme_identity" "$owned_readme_hash" || exit 1
if write_draft_readme >&7; then
finish_course_write_fd "$readme_target" "$readme_identity" 80c06e152d3570fd53bbaf16adc4ba091268dd33a0b6fbb3993df554b9e0c819 owned_readme_hash || exit 1
else
capture_failed_course_write_fd "$readme_target" "$readme_identity" owned_readme_hash || exit 1
exit 1
fi
finish_git05_critical_section
defer_git05_signals
run_git add -- README.md
bind_owned_index 80c06e152d3570fd53bbaf16adc4ba091268dd33a0b6fbb3993df554b9e0c819 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 || exit 1
finish_git05_critical_section
defer_git05_signals
incomplete_commit_output="$(run_git -c core.abbrev=40 commit -m 'docs: complete local git note')" || exit 1
printf '%s\n' "$incomplete_commit_output"
incomplete_oid="$(commit_output_oid "$incomplete_commit_output" 'docs: complete local git note')" || exit 1
observed_incomplete_head="$(run_git rev-parse HEAD)"
test "$observed_incomplete_head" = "$incomplete_oid" || exit 1
verify_incomplete_commit_candidate "$observed_incomplete_head" || exit 1
incomplete_head="$observed_incomplete_head"
bind_owned_index 80c06e152d3570fd53bbaf16adc4ba091268dd33a0b6fbb3993df554b9e0c819 42fa2adba9c1aa31981c95e6389c452d30754840095f9dbe45317994291a2677 || exit 1
finish_git05_critical_section
before_amend="$incomplete_head"
record_before_amend_oid
defer_git05_signals
write_final_git_local_files || exit 1
finish_git05_critical_section
defer_git05_signals
run_git add -- README.md notes/git-notes.md
bind_owned_index f7afcc991b7f17de1d3b705733e94d0ceb1ede0d614e9fba7435f89390ea85ec ce1ca9eabafe913d81160cb7de9871f4dc9647c1568262ffe2ed353630a6a79c || exit 1
finish_git05_critical_section
begin_owned_amend || exit 1
defer_git05_signals
amend_commit_output="$(run_git -c core.abbrev=40 commit --amend -m 'docs: complete local git notes')" || exit 1
printf '%s\n' "$amend_commit_output"
amend_oid="$(commit_output_oid "$amend_commit_output" 'docs: complete local git notes')" || exit 1
observed_c8_head="$(run_git rev-parse HEAD)"
test "$observed_c8_head" = "$amend_oid" || exit 1
verify_c8_candidate "$observed_c8_head" || exit 1
c8_head="$observed_c8_head"
bind_owned_index f7afcc991b7f17de1d3b705733e94d0ceb1ede0d614e9fba7435f89390ea85ec ce1ca9eabafe913d81160cb7de9871f4dc9647c1568262ffe2ed353630a6a79c || exit 1
finish_git05_critical_section
after_amend="$c8_head"
test "$before_amend" != "$after_amend" || exit 1
record_after_amend_oid
verify_git05_handoff || exit 1
begin_git05_handoff
trap - EXIT HUP INT TERM
)
advance_git_undo_recovery || { unset -f advance_git_undo_recovery; exit 1; }
unset -f advance_git_undo_recovery
观察结果
组合状态 MM notes/git-notes.md 的第一个 M 表示暂存区不同于 HEAD,第二个 M 表示工作区又不同于暂存区。两个 diff 分别展示两层变化;按顺序 restore 后,文件摘要重新等于 C7,状态为空。
amend 前后的对象 ID 不同,因为提交对象包含主题和 tree;最终历史只从 main 到达主题正确的 C8,不再把不完整提交当作分支历史的一部分。
常见问题
- 不确定提交是否已共享: 停止 amend,先与协作者确认。已共享历史应通过新提交更正。
- restore 路径写错: 停止。这里只恢复
notes/git-notes.md,不扩大到整个工作区。 - 前置状态不匹配: 不要强行继续。先找出错误 HEAD、额外 ref、dirty 路径或未完成操作。
- 高风险命令:
git reset --hard、git clean -fd和 reflog 恢复不属于本节实操;前两者可能丢失内容,后者需要独立的高级恢复课程。
完成检查
确认最新主题为 docs: complete local git notes,它的唯一父提交是 C7;git rev-list --count HEAD 为 8。仓库只保留 main,没有 remote、标签或未完成操作,工作区与暂存区干净。README 和 Git 练习记录都应是本节最终文本。
下一步
本地 Git 历史已经完整。后续 SSH 教程发布后,再学习远程连接的用途与安全模型;在那之前,不需要给这个练习仓库添加 remote。