跳到主要内容

状态、差异与历史

本节目标

继续使用上一节的同一个 dev-lab 根仓库,创建 Git 练习记录,并在提交前后回答三个问题:哪些路径发生变化、变化位于工作区还是暂存区、HEAD 指向的历史走到了哪里。

开始前状态

目标必须是 GIT-01 的精确完成状态:仓库根目录就是显式指定的 DEVLAB_DIR,当前只有 main 分支,HEAD 是主题为 chore: establish dev-lab baseline 的根提交,仓库级身份仍为 Dev Lab <dev-lab@example.invalid>,没有 remote、标签、额外分支或未完成操作,index 与工作区都干净。

必要原理

git status --short 给出路径所处状态的摘要。git diff -- <path> 比较工作区与暂存区,回答“尚未暂存的内容是什么”;git diff --staged -- <path> 比较暂存区与 HEAD,回答“下一次提交候选快照与当前提交有何不同”。两种差异方向不同,不能互相替代。

未跟踪文件尚未进入暂存区,所以普通 git diff 不会把整份新文件当作已跟踪差异。本节先观察它的 ?? 状态,再暂存空文件并写入规范内容,使同一路径真实出现 AM:左列 A 表示暂存区已有新增文件,右列 M 表示工作区还有未暂存修改。第二次 git add 才会把完整内容更新到候选快照。

动手完成

下面的受保护迁移先验证 C1 的分支、身份、父关系、精确 tree、路径和干净状态。它独占创建 notes/git-notes.md,依次输出未跟踪、未暂存与已暂存观察,再提交 C2。为避免验证后覆盖恰好同时出现的外来提交,保护脚本先创建精确的 C2 提交对象,再仅当 main 仍指向 C1 时更新分支;这与本节手工执行 git commit 得到的历史语义相同。所有 Git 调用都使用清洁环境,不读取全局配置,也不添加 remote。

运行位置: 任意安全目录,但必须显式设置同一个 DEVLAB_DIR预期结果: stdout 展示状态与两种 diff,最后历史含两笔提交;stderr 为空。失败处理: 若前置状态不符,停止并保留现状,先查明外来提交或暂存修改的来源。

advance_git_status_history() (
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" || {
printf '目标不是 GIT-01 创建的本地仓库。\n' >&2
exit 1
}

git_bin="$(PATH=/usr/bin:/bin command -v git)" || {
printf '未检测到 Git;请先按平台说明安装。\n' >&2
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 "$@"
}
local_config_names="$(read_local_config --name-only --list)" || exit 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; exit 1 ;;
esac
done <<< "$local_config_names"
test "$(read_local_config --get-all core.repositoryformatversion)" = 0 || exit 1
case "$(read_local_config --get-all core.filemode)" in true|false) ;; *) exit 1 ;; esac
test "$(read_local_config --get-all core.bare)" = false || exit 1
test "$(read_local_config --get-all core.logallrefupdates)" = true || exit 1
test "$(read_local_config --get-all core.hooksPath)" = /dev/null || exit 1
test "$(read_local_config --get-all core.fsmonitor)" = false || exit 1
test "$(read_local_config --get-all user.name)" = 'Dev Lab' || exit 1
test "$(read_local_config --get-all user.email)" = 'dev-lab@example.invalid' || exit 1
for optional_boolean in core.ignorecase core.precomposeunicode; do
optional_value="$(read_local_config --get-all "$optional_boolean" 2>/dev/null || true)"
case "$optional_value" in ''|true|false) ;; *) exit 1 ;; esac
done
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"
}
same_file_as_fd() {
local path_inode fd_inode
path_inode="$(file_inode "$1")" || return 1
fd_inode="$(file_inode "$2")" || return 1
test "$path_inode" = "$fd_inode"
}
has_any_execute_bit() {
local mode_text
mode_text="$(LC_ALL=C ls -ld "$1")" || return 1
mode_text="${mode_text%% *}"
[[ "$mode_text" == *[xstST]* ]]
}
require_directory() {
test -d "$devlab_dir/$1" && test ! -L "$devlab_dir/$1" || exit 1
}
require_c1_file() {
local expected_hash="$1"
local relative_path="$2"
local expected_class="$3"
test -f "$devlab_dir/$relative_path" && test ! -L "$devlab_dir/$relative_path" || exit 1
case "$expected_class" in
x) test -x "$devlab_dir/$relative_path" || exit 1 ;;
-) ! has_any_execute_bit "$devlab_dir/$relative_path" || exit 1 ;;
*) exit 1 ;;
esac
test "$(file_hash "$devlab_dir/$relative_path")" = "$expected_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 "$(run_git config --local --no-includes --get user.name)" = 'Dev Lab' || exit 1
test "$(run_git config --local --no-includes --get user.email)" = 'dev-lab@example.invalid' || exit 1
test "$(run_git log -1 --format=%s)" = 'chore: establish dev-lab baseline' || exit 1
test "$(run_git rev-list --count HEAD)" = 1 || exit 1
set -- $(run_git rev-list --parents -n 1 HEAD)
test "$#" -eq 1 || 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

for relative_path in config data notes output scripts; do require_directory "$relative_path"; done
expected_paths='.gitignore
README.md
config
config/bashrc.dev-lab
config/env.dev-lab
data
data/access.log
data/health-events.log
notes
notes/linux-notes.md
notes/shell-notes.md
output
output/health-report.txt
output/summary.txt
scripts
scripts/health-check.sh
scripts/permission-demo.sh
scripts/report.sh'
actual_paths="$(cd "$devlab_dir" && find . -path './.git' -prune -o -mindepth 1 -print | sed 's#^\./##' | LC_ALL=C sort)"
test "$actual_paths" = "$expected_paths" || { printf 'C1 工作树路径集合不匹配。\n' >&2; exit 1; }
expected_tracked='.gitignore
README.md
config/bashrc.dev-lab
config/env.dev-lab
data/access.log
data/health-events.log
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'
test "$(run_git ls-files)" = "$expected_tracked" || exit 1

while read -r expected_hash relative_path expected_class; do
require_c1_file "$expected_hash" "$relative_path" "$expected_class"
case "$expected_class" in
x) expected_mode=100755 ;;
-) expected_mode=100644 ;;
esac
case "$(run_git ls-files -s -- "$relative_path")" in
"$expected_mode "*) ;;
*) exit 1 ;;
esac
done <<'EXPECTED_C1_FILES'
04950b968fe37d63b990bf4d5cb3a5b5684fd59320205c10282df38a8120fdbe .gitignore -
f35c009dec20278b5df50693da43c54ae4b8e228a173fcb9f59e1b3aea5a2dc5 README.md -
882ef7d9502cc5d8145c5f8b0321dbd17b0c7d431db9421cbe390f1f86e94e12 config/bashrc.dev-lab -
00aec1c6d894bb334bef59a03fad5d6392f0a2c1e5517cb89b788c4ad72b390a config/env.dev-lab -
1df6e34d28da0483cc1e62523acc777597ce3178a77d089bf7ffcabb4b0ab254 data/access.log -
fe0119bfe33a541d816c33740f38b32280896ffce54bd7841985c7b1e6bd06e5 data/health-events.log -
e54276c7c8a2c2818aa48891030e31f7712fde6f5f42b70257c1a3e9582086dc notes/linux-notes.md -
d937ab49e62af7d2f5bbb92f457476afa2a9632a9d1c68135a30a98ffe9aed7d notes/shell-notes.md -
32c0404e72a9692ecac0a2877a98b8ae09fa90090c2af9dbdbc3c9da18a1cf1e output/health-report.txt -
fd45eca2af0f073a58b8a6291f64804b6e5eade93aa773c6d5e49832edd65591 output/summary.txt -
5fa78e594202c38e9004b65fe69c0281c509b00ad7c10b0c48cfd2b383720e4f scripts/health-check.sh x
3f54de46ae71a2b3af65bd3aeaf9c28bbf91409abc67c87f718c16c4961445e8 scripts/permission-demo.sh x
eb8bf1b36e7bd043f64665680b71d086a0a9aeb3c5b5a045fe2fb3e40bb52e37 scripts/report.sh x
EXPECTED_C1_FILES

devlab_parent="${devlab_dir%/*}"
test -n "$devlab_parent" || devlab_parent=/
devlab_identity="$(file_identity "$devlab_dir")" || exit 1
devlab_parent_identity="$(file_identity "$devlab_parent")" || exit 1
git_dir_identity="$(file_identity "$devlab_dir/.git")" || exit 1
notes_dir_identity="$(file_identity "$devlab_dir/notes")" || exit 1
c1_head="$(run_git rev-parse HEAD)" || exit 1
course_head=''
notes_identity=''
remove_owned_git_notes() {
test -n "$notes_identity" || return 0
(
cd -P -- "$devlab_dir/notes" || exit 1
test "$(file_identity .)" = "$notes_dir_identity" || exit 1
test "$(file_identity ..)" = "$devlab_identity" || exit 1
if test ! -e ./git-notes.md && test ! -L ./git-notes.md; then exit 0; fi
test -f ./git-notes.md && test ! -L ./git-notes.md || exit 1
test "$(file_identity ./git-notes.md)" = "$notes_identity" || exit 1
/bin/rm -f -- ./git-notes.md
)
}
restore_git_status_history() {
local cleanup_status=0 current_head=''
test "$(file_identity "$devlab_dir")" = "$devlab_identity" || return 1
test "$(file_identity "$devlab_parent")" = "$devlab_parent_identity" || return 1
test -d "$devlab_dir/.git" && test ! -L "$devlab_dir/.git" || return 1
test "$(file_identity "$devlab_dir/.git")" = "$git_dir_identity" || return 1
test "$(file_identity "$devlab_dir/notes")" = "$notes_dir_identity" || return 1
current_head="$(run_git rev-parse HEAD)" || return 1
if test "$current_head" = "$c1_head"; then
:
elif test -n "$course_head" && test "$current_head" = "$course_head"; then
run_git update-ref refs/heads/main "$c1_head" "$course_head" || return 1
else
cleanup_status=1
fi
run_git read-tree "$c1_head^{tree}" || cleanup_status=1
remove_owned_git_notes || cleanup_status=1
return "$cleanup_status"
}
cleanup_git_status_history_on_exit() {
local transition_status="$?"
trap - EXIT HUP INT TERM
restore_git_status_history || transition_status=1
exit "$transition_status"
}
arm_git_status_history_cleanup_signals() {
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
}
ignore_git_status_history_acquire_signals() {
trap '' HUP INT TERM
}
begin_git_status_history_handoff() {
trap '' HUP INT TERM
}
verify_git_status_history_handoff() {
local state_path
test "$(file_identity "$devlab_dir")" = "$devlab_identity" || return 1
test "$(file_identity "$devlab_parent")" = "$devlab_parent_identity" || return 1
test -d "$devlab_dir/.git" && test ! -L "$devlab_dir/.git" || return 1
test "$(file_identity "$devlab_dir/.git")" = "$git_dir_identity" || return 1
test -d "$devlab_dir/notes" && test ! -L "$devlab_dir/notes" || 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")" = 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || return 1
! has_any_execute_bit "$notes_target" || return 1
test -n "$course_head" || return 1
test "$(run_git rev-parse HEAD)" = "$course_head" || return 1
test "$(run_git rev-parse HEAD^)" = "$c1_head" || return 1
test "$(run_git write-tree)" = "$course_tree" || return 1
test "$(run_git rev-parse HEAD^{tree})" = "$course_tree" || return 1
test "$(run_git branch --show-current)" = main || return 1
test -z "$(run_git status --porcelain=v1 --untracked-files=all)" || return 1
test "$(run_git for-each-ref --format='%(refname)' refs)" = refs/heads/main || return 1
test -z "$(run_git remote)" || return 1
test "$(run_git config --local --no-includes --get user.name)" = 'Dev Lab' || return 1
test "$(run_git config --local --no-includes --get user.email)" = 'dev-lab@example.invalid' || return 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" || return 1
done
}
trap cleanup_git_status_history_on_exit EXIT
arm_git_status_history_cleanup_signals

notes_target="$devlab_dir/notes/git-notes.md"
test ! -e "$notes_target" && test ! -L "$notes_target" || exit 1
ignore_git_status_history_acquire_signals
set -o noclobber
if exec 9> "$notes_target"; then
:
else
set +o noclobber
exit 1
fi
set +o noclobber
if candidate_notes_identity="$(file_identity "$notes_target")"; then
:
else
if same_file_as_fd "$notes_target" /dev/fd/9; then /bin/rm -f -- "$notes_target"; fi
exec 9>&-
exit 1
fi
same_file_as_fd "$notes_target" /dev/fd/9 || { exec 9>&-; exit 1; }
notes_identity="$candidate_notes_identity"
arm_git_status_history_cleanup_signals
run_git status --short -- notes/git-notes.md
run_git add -- notes/git-notes.md
printf '%s\n' \
'# Git 练习记录' \
'' \
'## 工作区模型' \
'' \
'- 工作区保存当前文件。' \
'- 暂存区保存下一次提交的候选快照。' \
'- HEAD 指向当前分支的提交。' \
'' \
'## 分支工作流' \
'' \
'课程将在分支练习中补充这里。' \
'' \
'## 排错顺序' \
'' \
'课程将在冲突练习中补充这里。' \
'' \
'## 安全恢复' \
'' \
'课程将在恢复练习中补充这里。' >&9
exec 9>&-
test -f "$notes_target" && test ! -L "$notes_target" || exit 1
! has_any_execute_bit "$notes_target" || exit 1
test "$(file_hash "$notes_target")" = 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be || exit 1

test "$(run_git status --short -- notes/git-notes.md)" = 'AM notes/git-notes.md'
run_git status --short
run_git diff --no-ext-diff -- notes/git-notes.md
run_git add -- notes/git-notes.md
run_git diff --staged --no-ext-diff -- notes/git-notes.md
course_tree="$(run_git write-tree)" || exit 1
course_head="$(printf '%s\n' 'docs: add git practice journal' | run_git commit-tree "$course_tree" -p "$c1_head")" || exit 1
run_git update-ref refs/heads/main "$course_head" "$c1_head" || exit 1
run_git log --oneline --decorate

test "$(run_git log -1 --format=%s)" = 'docs: add git practice journal'
test "$(run_git log -1 --format=%s HEAD^)" = 'chore: establish dev-lab baseline'
test "$(run_git rev-list --count HEAD)" = 2
set -- $(run_git rev-list --parents -n 1 HEAD)
test "$#" -eq 2
test "$(run_git rev-parse HEAD^)" = "$2"
test "$(file_hash "$notes_target")" = 5250834cba284fc1d9017836f1ac056432d341020531b6c484fa6da4ae1834be
case "$(run_git ls-files -s -- notes/git-notes.md)" in
'100644 '*) ;;
*) exit 1 ;;
esac
test -z "$(run_git status --porcelain=v1 --untracked-files=all)"
test "$(run_git for-each-ref --format='%(refname)' refs)" = refs/heads/main
test -z "$(run_git remote)"
begin_git_status_history_handoff
verify_git_status_history_handoff || exit 1
trap - EXIT HUP INT TERM
)
advance_git_status_history || {
unset -f advance_git_status_history
exit 1
}
unset -f advance_git_status_history

观察结果

迁移会实际运行以下观察顺序;在手工复盘时也按这个方向阅读:

git status --short
git diff -- notes/git-notes.md
git add -- notes/git-notes.md
git diff --staged -- notes/git-notes.md
git commit -m 'docs: add git practice journal'
git log --oneline --decorate

第一次完整状态显示 AM notes/git-notes.md:暂存区保存空文件候选,工作区保存完整内容。普通 diff 展示从空候选到完整工作区的变化;第二次 git add 后,staged diff 展示从 HEAD 到完整候选快照的变化。提交完成后,短状态为空,日志最上方是新提交,下一行是首次提交。

常见问题

  • 普通 diff 没有输出: 先看 git status --short。纯未跟踪文件不在普通 diff 的比较范围内;不要因此误以为文件已被保存。
  • staged diff 没有输出: 说明暂存区与 HEAD 相同,或你尚未把目标路径加入暂存区。
  • 出现意外路径或提交: 停止迁移。课程不会替你覆盖外来历史、清空 index 或删除文件。
  • 日志打开分页器: 受保护迁移固定使用 cat;手工只读观察时可按当前终端的退出提示返回。

完成检查

确认 git status --short 无输出,main 上正好有两笔可达提交,最新主题是 docs: add git practice journal,其父提交主题是 chore: establish dev-lab baselinenotes/git-notes.md 必须是普通非链接文件,仓库仍然没有 remote、标签或额外分支。

下一步

下一节将从第二笔提交创建短期功能分支,在分支上补充“分支工作流”,再通过显式非快进合并回到 main。分支是指向提交的引用,不是另一份目录副本。