分支与合并
本节目标
继续使用同一个 dev-lab 根仓库:从 main 创建功能分支,在分支上完成一笔独立提交,再通过显式非快进合并回到主线。完成后只保留 main,但历史中仍能看到两个父提交的合并节点。
开始前状态
目标必须是 GIT-02 的精确 C2:当前分支为 main,最新主题为 docs: add git practice journal,其父提交主题为 chore: establish dev-lab baseline,notes/git-notes.md 与上一节完全一致。仓库只有 refs/heads/main,没有 remote、标签、其他引用、未完成操作或未提交变化。
必要原理
分支是指向提交的可移动引用,不是另一份目录副本。git switch -c 创建新引用并让 HEAD 指向它;在新分支提交时,只有该引用向前移动。
--no-ff 要求 Git 即使可以快进,也创建一个显式合并提交。它的第一父提交是合并前的 main C2,第二父提交是功能分支 C3。删除已合并的短期分支只删除引用,C3 仍可从合并提交到达。
动手完成
下面的迁移只从显式的 DEVLAB_DIR 取目标。它先锁定 C2 的路径、文件、分支和对象 ID,失败或收到中断信号时只回滚还由本迁移持有的资源。合并完成后,删除前会再用第二父和精确 ref ID 确认所有权;若 ref 已被替换,保留现场并失败,不会强制删除。
课程文件先以非截断方式打开并绑定目标 FD。Linux 通过已经绑定的 /dev/fd/7 截断;macOS 的 /dev/fd 不接受带截断标志的重开,因此 Darwin 分支只在确认 /usr/bin/perl 可执行后,对继承的 FD 7 调用 ftruncate,绝不向适配器传入 pathname。适配器缺失或平台未知时会在写入前失败。
运行位置: 任意安全目录,但必须显式设置同一个 DEVLAB_DIR。预期结果: main 指向 C4,工作区干净,功能分支已删除。
advance_git_branch_merge() (
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"
test -d "$devlab_dir/notes" && test ! -L "$devlab_dir/notes" || exit 1
test -f "$notes_target" && test ! -L "$notes_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 diff.external= -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
}
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" 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 "$actual_hash" = "$expected_hash" || return 1
test "$(file_identity "$target")" = "$expected_identity" || 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_value
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_value in core.ignorecase core.precomposeunicode; do
case "$(read_local_config --get-all "$optional_value" 2>/dev/null || true)" in ''|true|false) ;; *) return 1 ;; esac
done
}
verify_git_tree_manifest() {
local commit="$1" notes_hash="$2" 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
notes/git-notes.md
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 "$commit")" || 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=ac9a5a22aabc4a25cf371486326d0f8fc9c15076; test "$mode" = 100644 ;;
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 "$mode" = 100644 && test "$(blob_hash "$blob_oid")" = "$notes_hash" ;;
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_c2_notes() {
printf '%s\n' '# Git 练习记录' '' '## 工作区模型' '' \
'- 工作区保存当前文件。' '- 暂存区保存下一次提交的候选快照。' '- HEAD 指向当前分支的提交。' '' \
'## 分支工作流' '' '课程将在分支练习中补充这里。' '' '## 排错顺序' '' '课程将在冲突练习中补充这里。' '' \
'## 安全恢复' '' '课程将在恢复练习中补充这里。'
}
write_c3_notes() {
printf '%s\n' '# Git 练习记录' '' '## 工作区模型' '' \
'- 工作区保存当前文件。' '- 暂存区保存下一次提交的候选快照。' '- HEAD 指向当前分支的提交。' '' \
'## 分支工作流' '' '- 在功能分支完成独立修改。' '- 合并前先确认当前分支和工作区状态。' '' \
'## 排错顺序' '' '课程将在冲突练习中补充这里。' '' '## 安全恢复' '' '课程将在恢复练习中补充这里。'
}
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
for state_path in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-apply rebase-merge; do
test ! -e "$devlab_dir/.git/$state_path" && test ! -L "$devlab_dir/.git/$state_path" || exit 1
done
c2_head="$(run_git rev-parse HEAD)" || exit 1
test "$(run_git log -1 --format=%s "$c2_head")" = 'docs: add git practice journal' || exit 1
test "$(run_git rev-list --count "$c2_head")" = 2 || exit 1
c1_head="$(run_git rev-parse "$c2_head^")" || exit 1
test "$(run_git log -1 --format=%s "$c1_head")" = 'chore: establish dev-lab baseline' || exit 1
set -- $(run_git rev-list --parents -n 1 "$c2_head")
test "$#" -eq 2 && test "$2" = "$c1_head" || exit 1
test "$(file_hash "$notes_target")" = 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || exit 1
test "$(run_git hash-object "$notes_target")" = "$(run_git rev-parse "$c2_head:notes/git-notes.md")" || exit 1
verify_git_tree_manifest "$c2_head" 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || exit 1
test "$(run_git diff-tree --no-commit-id --name-only -r "$c1_head" "$c2_head")" = notes/git-notes.md || exit 1
test "$(file_identity "$devlab_dir/.git/index")" = "$index_identity" || exit 1
test "$(file_hash "$devlab_dir/.git/index")" = "$index_hash" || exit 1
test "$(run_git write-tree)" = "$(run_git rev-parse "$c2_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
feature_ref_oid=''; feature_tip=''; c4_head=''; branch_merge_handoff=0
bind_owned_index() {
local expected_notes_hash="$1" index_tree candidate_index_identity candidate_index_hash
test -f "$devlab_dir/.git/index" && test ! -L "$devlab_dir/.git/index" || return 1
index_tree="$(run_git write-tree)" || return 1
candidate_index_identity="$(file_identity "$devlab_dir/.git/index")" || return 1
verify_git_tree_manifest "$index_tree" "$expected_notes_hash" || return 1
candidate_index_hash="$(file_hash "$devlab_dir/.git/index")" || return 1
test "$(file_identity "$devlab_dir/.git/index")" = "$candidate_index_identity" || return 1
index_identity="$candidate_index_identity"
index_hash="$candidate_index_hash"
}
rebind_owned_config_after_git() {
local candidate_config_identity candidate_config_hash
test -f "$devlab_dir/.git/config" && test ! -L "$devlab_dir/.git/config" || return 1
candidate_config_identity="$(file_identity "$devlab_dir/.git/config")" || return 1
candidate_config_hash="$(file_hash "$devlab_dir/.git/config")" || return 1
test "$candidate_config_hash" = "$config_hash" || return 1
test "$(file_identity "$devlab_dir/.git/config")" = "$candidate_config_identity" || return 1
config_identity="$candidate_config_identity"
}
bind_owned_notes() {
local expected_hash="$1" candidate_notes_identity
test -f "$notes_target" && test ! -L "$notes_target" || return 1
candidate_notes_identity="$(file_identity "$notes_target")" || return 1
test "$(file_hash "$notes_target")" = "$expected_hash" || return 1
test "$(file_identity "$notes_target")" = "$candidate_notes_identity" || return 1
notes_identity="$candidate_notes_identity"
}
verify_branch_creation_candidate() {
local candidate="$1"
test "$candidate" = "$c2_head" || return 1
test "$(run_git rev-parse HEAD)" = "$candidate" || return 1
test "$(run_git rev-parse refs/heads/feature/git-workflow)" = "$candidate" || return 1
test "$(run_git log -1 --format=%s "$candidate")" = 'docs: add git practice journal' || return 1
verify_git_tree_manifest "$candidate" 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || return 1
test "$(run_git rev-parse refs/heads/feature/git-workflow)" = "$candidate" || return 1
}
verify_c3_candidate() {
local candidate="$1" parents
test "$(run_git rev-parse HEAD)" = "$candidate" || return 1
test "$(run_git rev-parse refs/heads/feature/git-workflow)" = "$candidate" || return 1
test "$(run_git log -1 --format=%s "$candidate")" = 'docs: record branch workflow' || return 1
parents="$(run_git rev-list --parents -n 1 "$candidate")" || return 1
set -- $parents
test "$#" -eq 2 && test "$2" = "$c2_head" || return 1
verify_git_tree_manifest "$candidate" 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || return 1
test "$(run_git diff-tree --no-commit-id --name-only -r "$c2_head" "$candidate")" = notes/git-notes.md || return 1
test "$(run_git rev-parse refs/heads/feature/git-workflow)" = "$candidate" || return 1
}
verify_c4_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")" = 'merge: integrate git workflow' || return 1
parents="$(run_git rev-list --parents -n 1 "$candidate")" || return 1
set -- $parents
test "$#" -eq 3 && test "$2" = "$c2_head" && test "$3" = "$feature_tip" || return 1
verify_git_tree_manifest "$candidate" 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || return 1
test "$(run_git rev-parse "$candidate^{tree}")" = "$(run_git rev-parse "$feature_tip^{tree}")" || return 1
test "$(run_git rev-parse refs/heads/main)" = "$candidate" || return 1
}
verify_owned_config_and_index() {
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
}
restore_branch_merge() {
local current_head current_feature current_main
test "$branch_merge_handoff" -eq 0 || return 1
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
verify_owned_config_and_index || 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
current_head="$(run_git rev-parse HEAD)" || return 1
current_main="$(run_git rev-parse refs/heads/main)" || return 1
if test "$current_main" = "$c2_head"; then :
elif test -n "$c4_head" && test "$current_main" = "$c4_head"; then run_git update-ref refs/heads/main "$c2_head" "$c4_head" || return 1
else return 1; fi
if test "$current_head" = "$c2_head"; then :
elif test -n "$feature_ref_oid" && test "$current_head" = "$feature_ref_oid"; then :
elif test -n "$c4_head" && test "$current_head" = "$c4_head"; then :
else return 1; fi
if current_feature="$(run_git rev-parse --verify refs/heads/feature/git-workflow 2>/dev/null)"; then
test -n "$feature_ref_oid" && test "$current_feature" = "$feature_ref_oid" || return 1
run_git update-ref -d refs/heads/feature/git-workflow "$feature_ref_oid" || return 1
fi
run_git symbolic-ref HEAD refs/heads/main || return 1
run_git read-tree "$c2_head^{tree}" || return 1
bind_owned_index 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || return 1
open_course_rewrite_fd "$notes_target" "$notes_identity" || return 1
begin_bound_course_rollback_write
truncate_bound_course_fd "$(/usr/bin/uname -s)" || { exec 7>&-; return 1; }
write_c2_notes >&7 || { exec 7>&-; return 1; }
finish_course_write_fd "$notes_target" "$notes_identity" 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || return 1
}
cleanup_branch_merge_on_exit() {
local transition_status="$?"
trap - EXIT HUP INT TERM
restore_branch_merge || transition_status=1
exit "$transition_status"
}
arm_branch_merge_signals() { trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM; }
ignore_branch_merge_signals() { trap '' HUP INT TERM; }
begin_branch_merge_handoff() { trap '' HUP INT TERM; branch_merge_handoff=1; }
verify_branch_merge_before_delete() {
verify_owned_config_and_index || return 1
test "$(run_git branch --show-current)" = main || return 1
test "$(run_git rev-parse HEAD)" = "$c4_head" || return 1
test "$(run_git rev-parse HEAD^1)" = "$c2_head" || return 1
test "$(run_git rev-parse HEAD^2)" = "$feature_tip" || return 1
test "$(run_git rev-parse refs/heads/feature/git-workflow)" = "$feature_tip" || return 1
test "$(file_identity "$notes_target")" = "$notes_identity" || return 1
test "$(file_hash "$notes_target")" = 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || return 1
verify_git_tree_manifest "$c4_head" 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || return 1
test "$(run_git rev-parse "$c4_head^{tree}")" = "$(run_git rev-parse "$feature_tip^{tree}")" || return 1
}
verify_branch_merge_handoff() {
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
verify_owned_config_and_index || return 1
test "$(file_identity "$devlab_dir/notes")" = "$notes_dir_identity" || return 1
test "$(run_git branch --show-current)" = main || return 1
test "$(run_git rev-parse HEAD)" = "$c4_head" || return 1
test "$(run_git rev-parse HEAD^1)" = "$c2_head" || return 1
test "$(run_git rev-parse HEAD^2)" = "$feature_tip" || return 1
test "$(file_identity "$notes_target")" = "$notes_identity" || return 1
test "$(file_hash "$notes_target")" = 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || 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
}
trap cleanup_branch_merge_on_exit EXIT
arm_branch_merge_signals
ignore_branch_merge_signals
run_git switch -c feature/git-workflow || exit 1
feature_ref_oid="$c2_head"
candidate_feature_ref="$(run_git rev-parse refs/heads/feature/git-workflow)"
verify_branch_creation_candidate "$candidate_feature_ref" || { printf '创建分支后的候选引用验证失败。\n' >&2; exit 1; }
feature_ref_oid="$candidate_feature_ref"
bind_owned_index 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || exit 1
arm_branch_merge_signals
open_course_rewrite_fd "$notes_target" "$notes_identity" 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || exit 1
begin_bound_course_write
truncate_bound_course_fd "$(/usr/bin/uname -s)" || { exec 7>&-; exit 1; }
write_c3_notes >&7 || { exec 7>&-; exit 1; }
finish_course_write_fd "$notes_target" "$notes_identity" 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || exit 1
ignore_branch_merge_signals
run_git add -- notes/git-notes.md
bind_owned_index 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || exit 1
run_git commit -m 'docs: record branch workflow'
candidate_feature_tip="$(run_git rev-parse refs/heads/feature/git-workflow)"
verify_c3_candidate "$candidate_feature_tip" || { printf 'C3 候选引用验证失败。\n' >&2; exit 1; }
feature_tip="$candidate_feature_tip"
feature_ref_oid="$candidate_feature_tip"
bind_owned_index 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || exit 1
arm_branch_merge_signals
ignore_branch_merge_signals
run_git switch main
bind_owned_notes 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || exit 1
bind_owned_index 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || exit 1
arm_branch_merge_signals
test "$(run_git rev-parse HEAD)" = "$c2_head" || exit 1
test "$(run_git rev-parse refs/heads/main)" = "$c2_head" || exit 1
test "$(run_git rev-parse refs/heads/feature/git-workflow)" = "$feature_tip" || exit 1
ignore_branch_merge_signals
run_git merge --no-ff -m 'merge: integrate git workflow' feature/git-workflow
candidate_c4_head="$(run_git rev-parse HEAD)"
verify_c4_candidate "$candidate_c4_head" || { printf 'C4 候选引用验证失败。\n' >&2; exit 1; }
c4_head="$candidate_c4_head"
bind_owned_notes 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || exit 1
bind_owned_index 9796b2da5468c67a7b4274d063d7a9fb10b417c1a8caabc6ae10ac095047f342 || exit 1
arm_branch_merge_signals
begin_branch_merge_handoff
verify_branch_merge_before_delete || exit 1
run_git branch -d feature/git-workflow
rebind_owned_config_after_git || exit 1
verify_branch_merge_handoff || exit 1
trap - EXIT HUP INT TERM
)
advance_git_branch_merge || { unset -f advance_git_branch_merge; exit 1; }
unset -f advance_git_branch_merge
观察结果
核心顺序是:
git switch -c feature/git-workflow
git add -- notes/git-notes.md
git commit -m 'docs: record branch workflow'
git switch main
git merge --no-ff -m 'merge: integrate git workflow' feature/git-workflow
git log --oneline --graph --decorate
git branch -d feature/git-workflow
日志图中的合并提交会有两条父线:第一条回到 C2,第二条回到 C3。删除功能分支后,git branch --list 只显示 main,但 C3 仍在图中。
常见问题
- 合并变成快进: 检查是否实际使用了
--no-ff。本节为了观察父关系,故意保留合并节点。 - 同名分支已存在: 停止并确认来源;迁移不会覆盖或强制删除它。
- 工作区不干净: 先用
git status --short确认变化属于谁,不要为了继续课程而清空未知修改。 - 删除前检查失败: 保留当前诊断状态。这通常意味着分支引用在合并后又被改动,不应猜测或强制删除。
完成检查
确认当前分支是 main,git status --short 无输出,只有 refs/heads/main,没有 remote。最新提交主题必须是 merge: integrate git workflow,第一父是 C2,第二父是主题为 docs: record branch workflow 的 C3。
下一步
下一节从这个 C4 同时创建主线修改和功能分支修改,让两边更改同一个占位段落,产生一次可预测、可放弃、可解决的文本冲突。