用 Git 为本地文件打造一台时光机

背景:那些悄悄溜走的文件版本

日常开发里总有这样的瞬间:刚改好的配置被一次误操作覆盖、调试时改了一堆临时代码想回到某个中间状态、或者纯粹想知道过去几小时里工作目录到底发生了什么。正式的版本控制是为协作提交设计的,每一次提交都要求人为介入、写清楚信息、经过 review。而我们要的是另一种能力:随改随记、悄无声息、随时回拨。

如果能把 Git 的部分能力单独抽出来,只服务于本地文件的快照记录,就能得到一台时刻运转的时光机。这篇文章记录的,就是把这件事从想法落地为三个脚本的过程。

起点:最朴素的念头

最直接的想法是,工作目录本来就是一个 Git 仓库,文件变化时自动跑一次 git addgit commit 不就行了?

这个念头马上撞上第一堵墙。项目仓库里那些有意义的提交,是开发者认真组织过的:功能拆分、信息规范、commit message 交代清楚来龙去脉。如果让一个脚本在每次文件变动时都往里塞一条 [Auto] file changed,主仓库的历史会被迅速污染,真正有价值的提交会被淹没在噪音里。两个用途------正式的版本管理和自动的快照记录------必须彻底分开。

关键一步:把仓库和工作目录拆开

Git 有两个参数平时很少被显式使用,却正好能解开这个结:--git-dir 指定仓库元数据存放的位置,--work-tree 指定被追踪的工作目录。它们让仓库和工作目录可以不再绑死在一起。

把这两个参数抽成变量,方便后续每条命令复用:

bash 复制代码
GD="--git-dir=.git_local"
WT="--work-tree=."

--git-dir 指向一个名为 .git_local 的目录,它就是快照仓库的独立存储位置;--work-tree 指向当前工作目录 .,也就是被监控的文件范围。这样一来,所有快照操作都走 .git_local,而项目原本的 .git 完全感知不到这件事,两者井水不犯河水。

初始化这个隐形仓库只需一行:

bash 复制代码
git --git-dir=.git_local --work-tree=. init

第一堵墙拆掉了:快照有了自己的家,不再骚扰主仓库。

自动化:让文件变化自己走进仓库

仓库准备好,接下来要让提交自动发生。这需要两样东西配合:一个在后台盯着文件变化的监听工具(比如 chokidar),以及一个被它调用的提交脚本。每当文件增删改,监听工具就触发一次脚本,脚本负责把变化打包成一次提交。

但这里藏着第二堵墙:并发。文件监听工具往往会在短时间内连续触发多次事件------保存一次文件可能先触发修改事件、再触发属主变化事件,批量操作更是会在一瞬间涌出一串。如果上一次提交还没跑完,下一次就挤进来,Git 的 index.lock 会立刻报冲突,脚本失败、状态混乱。

解法是加一把锁。脚本启动时检查一个锁文件是否存在,锁里记着上一次执行的进程号;如果那个进程还活着,说明上一轮还没结束,这次就直接退出,把变化留给下一次提交去吸收。进程正常结束时,通过 trap 注册的清理动作自动删掉锁文件,保证不会留下死锁卡住后续执行。

锁机制就位后,提交动作本身可以做得非常朴素:add -A 一次性暂存所有变化,用固定的 [Auto] file changed 作为提交信息。快照的价值在于内容而非信息文案,固定文案反而能让真正的变化在日志里更突出。当没有真正变化时提交会失败,用 || exit 0 静默吞掉,避免无意义的报错噪音。

给变化留一份可读的台账

光有提交记录还不够直观。git log 看到的是一条条哈希,想知道某次提交动了哪些文件,还得逐条 git show 翻看。对于一个时刻在累积的快照系统,我们需要一份更友好的台账,让人扫一眼就知道每次提交发生了什么。

日志设计成两层结构。第一层是这次提交的简短哈希和时间,让人快速定位;第二层用 git show --name-status 列出本次提交涉及的文件以及它们的状态------新增、修改还是删除。文件状态行经过 sed 做缩进处理,在视觉上从属于上方的提交信息,读起来层次分明。

bash 复制代码
HASH=$(git $GD rev-parse --short HEAD)
DATE=$(git $GD log -1 --format='%ad')
{
  echo "commit $HASH"
  echo "Date:   $DATE"
  echo ""
  git $GD show --name-status --format='' HEAD | sed 's/^/    /'
  echo ""
} >> "$LOG_FILE"

日志路径通过脚本参数传入,默认落到 monitor.log,方便按项目区分。借助 exec > >(tee -a "$LOG_FILE") 2>&1,脚本后续的标准输出和错误输出都会同时落到终端和日志文件,既不影响实时观察,也留下了完整记录。

到这里,快照系统的核心已经成形:监听触发、互斥提交、可读日志。但还有一个体验上的缺口。

查看的难题:两套历史如何并存

快照仓库藏在 .git_local 里,平时直接敲 git loggit diff,Git 默认去找的是 .git,看到的是项目主仓库的历史。想查看快照就得每条命令都带上一长串 --git-dir=.git_local --work-tree=.,实在繁琐。

能不能让普通的 Git 命令直接指向快照仓库?这里用到一个常被忽略的细节:Git 在工作目录里寻找 .git 时,既接受一个目录,也接受一个文件。如果 .git 是一个文件,Git 会读取其中一行 gitdir: 某路径,然后去那个路径找真正的仓库。

这给了我们一个巧妙的切换办法。切到快照视图时,先把真正的项目仓库目录 .git 备份成 .git_bak,再写一个 .git 文件,内容是 gitdir: .git_local。此后所有不带参数的 Git 命令都会自动指向快照仓库,git loggit showgit diff 全部照常工作,体验和日常完全一致。

切回项目视图时反向操作:删掉 .git 文件,把 .git_bak 还原回 .git。整个过程对使用者透明,只是多了一个切换动作。

脚本还顺带提供了状态查看:能识别当前处于哪种视图,并直接打印最近五条提交,省去再敲一次 git log 的麻烦。不带参数时默认切到快照视图,因为查看快照是更频繁的需求。

不止记录结果,还要记录过程

文件快照记录的是变化的结果:哪些文件被改了。但有时候我们更想知道,是什么操作带来了这些变化------跑了哪条命令、耗时多久、成功没有。补上这一环,变化的来龙去脉才算完整。

这需要一个通用的命令执行脚本,把任意命令包起来执行,同时记录过程。设计上它要解决三件事:把命令的真实输出和耗时留下来、把退出码如实反映给调用方、不让日志无限膨胀。

前两件事靠组合 "$@"PIPESTATUS[0] 实现。"$@" 原样执行传入的命令,PIPESTATUS[0] 在管道之后仍能拿到命令本身的退出码,而不是下游工具的退出码。配合开始和结束的时间戳,耗时被格式化成小时、分钟、秒的可读形式写进日志。日志头尾各写一段分隔信息,标明命令、工作目录、起止时间和总耗时,让单次执行的记录自成一个完整段落。

第三件事靠日志轮转。每次有新命令执行时,如果旧日志存在,就按时间戳给它改名备份;备份超过设定数量时,按时间排序删掉最老的,只保留最近若干份。这样既留得住近期历史,又不会让磁盘上的日志无限增长。

把执行脚本和快照脚本配合使用,就形成了完整的证据链:某条命令在什么时间执行、耗时多久、成功与否,命令结束后工作目录发生了哪些文件变化,全部有据可查。

三段完整实现

下面是三个脚本的完整代码。把它们放在工作目录下,配上一个文件监听工具调用 commit.sh,再视需要用 switch_git_view.sh 切换视图、用 execute.sh 包裹耗时命令,整套快照系统就能运转起来。

第一段,自动提交脚本,由文件监听工具调用:

bash 复制代码
#!/bin/bash
# 由 chokidar 调用的自动提交脚本

GD="--git-dir=.git_local"
WT="--work-tree=."
LOG_FILE="${1:-monitor.log}"

# PID 文件锁,防止并发执行导致 index.lock 冲突
LOCK="/tmp/.commit_sh.lock"
if [ -f "$LOCK" ]; then
  OLD_PID=$(cat "$LOCK")
  kill -0 "$OLD_PID" 2>/dev/null && exit 0
fi
echo $$ > "$LOCK"
trap 'rm -f "$LOCK"' EXIT

# 暂存并提交
git $GD $WT add -A
git $GD $WT commit -m "[Auto] file changed" || exit 0

# 记录日志:第一层 git log 信息,第二层文件变化
mkdir -p "$(dirname "$LOG_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1

HASH=$(git $GD rev-parse --short HEAD)
DATE=$(git $GD log -1 --format='%ad')
{
  echo "commit $HASH"
  echo "Date:   $DATE"
  echo ""
  git $GD show --name-status --format='' HEAD | sed 's/^/    /'
  echo ""
} >> "$LOG_FILE"


# git --git-dir=.git_local --work-tree=. status
# git --git-dir=.git_local --work-tree=. log

# git config --global core.autocrlf true

第二段,视图切换脚本,在快照仓库与项目仓库之间切换:

bash 复制代码
#!/bin/bash
# ============================================================
# Git 视图切换脚本
# 在 .git(正常项目提交)和 .git_local(监控自动提交)之间切换
# 
# 用法:
#   bash switch_git_view.sh          # 切换到 .git_local 视图
#   bash switch_git_view.sh normal   # 切换回 .git 视图
#   bash switch_git_view.sh status   # 查看当前状态
# ============================================================

# 检查环境
if [ ! -d ".git" ] && [ ! -f ".git" ]; then
    echo "[ERROR] 当前目录没有 .git 目录或文件"
    exit 1
fi

if [ ! -d ".git_local" ]; then
    echo "[ERROR] 当前目录没有 .git_local 目录"
    exit 1
fi

show_status() {
    if [ -f ".git" ]; then
        local target=$(cat .git 2>/dev/null | grep "gitdir:" | sed 's/gitdir: //')
        if [ -n "$target" ]; then
            echo "[STATUS] 当前视图: .git_local (监控自动提交)"
            echo "--------- .git_local 最近提交 ---------"
            git --no-pager log --oneline -5
        else
            echo "[STATUS] 未知状态"
        fi
    elif [ -d ".git" ]; then
        echo "[STATUS] 当前视图: .git (正常项目提交)"
        echo "--------- .git 最近提交 ---------"
        git --no-pager log --oneline -5
    fi
}

switch_to_local() {
    if [ -f ".git" ]; then
        echo "[INFO] 已经是 .git_local 视图"
        show_status
        return
    fi
    
    # 备份 .git 目录到 .git_bak
    echo "[INFO] 切换中: .git -> .git_local ..."
    if [ -d ".git_bak" ]; then
        rm -rf .git_bak
    fi
    cp -r .git .git_bak
    rm -rf .git
    
    # 创建 .git 文件指向 .git_local
    echo "gitdir: .git_local" > .git
    
    echo "[OK] 已切换到 .git_local 视图"
    show_status
}

switch_to_normal() {
    if [ -d ".git" ]; then
        echo "[INFO] 已经是 .git 视图"
        show_status
        return
    fi
    
    if [ ! -d ".git_bak" ]; then
        echo "[ERROR] 找不到 .git_bak 目录,无法还原"
        exit 1
    fi
    
    # 删除 .git 文件,还原 .git_bak 目录
    echo "[INFO] 切换中: .git_local -> .git ..."
    rm -f .git
    cp -r .git_bak .git
    rm -rf .git_bak
    
    echo "[OK] 已切换到 .git 视图"
    show_status
}

case "${1:-local}" in
    local|monitor)
        switch_to_local
        ;;
    normal|project)
        switch_to_normal
        ;;
    status|show)
        show_status
        ;;
    *)
        echo "用法: bash switch_git_view.sh [local|normal|status]"
        echo ""
        echo "  local    - 切换到 .git_local 视图(监控自动提交)"
        echo "  normal   - 切换到 .git 视图(正常项目提交)"
        echo "  status   - 查看当前视图状态"
        exit 1
        ;;
esac

第三段,通用命令执行脚本,记录命令执行的耗时与结果:

bash 复制代码
#!/bin/bash
# ============================================================
# execute.sh - 通用命令执行脚本
# 功能:执行耗时命令,统计耗时,并将输出写入日志文件
# 用法:./execute.sh <command> [args...]
# 示例:./execute.sh mvn compile
#       ./execute.sh mvn -pl mkpt-upps/mkpt-upps-biz test -am
# ============================================================

# ---------- 参数校验 ----------
if [ $# -eq 0 ]; then
  echo "用法: $0 <command> [args...]"
  echo "示例: $0 mvn compile"
  exit 1
fi

# ---------- 预先执行初始化脚本(如果存在) ----------
PROJECT_DIR="$(pwd)"
INIT_SCRIPT="${PROJECT_DIR}/temp/start/init.sh"

if [ -f "${INIT_SCRIPT}" ]; then
  echo "[INFO] 检测到初始化脚本: ${INIT_SCRIPT}"
  echo "[INFO] 正在执行初始化脚本..."
  source "${INIT_SCRIPT}"
  echo "[INFO] 初始化脚本执行完成"
  echo "------------------------------------------------------------"
fi

# ---------- 日志目录与文件 ----------
LOG_DIR="temp/output"
LOG_FILE="${LOG_DIR}/console.log"
mkdir -p "${LOG_DIR}"

# ---------- 日志轮转:备份旧日志,最多保留 MAX_LOG_FILES 个 ----------
MAX_LOG_FILES=10
if [ -f "${LOG_FILE}" ]; then
  BACKUP_SUFFIX=$(date "+%Y%m%d_%H%M%S")
  BACKUP_FILE="${LOG_DIR}/console_${BACKUP_SUFFIX}.log"
  mv "${LOG_FILE}" "${BACKUP_FILE}"
  echo "[INFO] 日志轮转: ${LOG_FILE} -> ${BACKUP_FILE}"

  # 清理超出数量的旧备份
  BACKUP_COUNT=$(ls -1 "${LOG_DIR}"/console_*.log 2>/dev/null | wc -l)
  if [ "${BACKUP_COUNT}" -gt ${MAX_LOG_FILES} ]; then
    ls -1 "${LOG_DIR}"/console_*.log 2>/dev/null | sort | head -n $((BACKUP_COUNT - MAX_LOG_FILES)) | while read OLD_LOG; do
      rm -f "${OLD_LOG}"
      echo "[INFO] 清理旧日志: ${OLD_LOG}"
    done
  fi
fi

exec > >(tee -a $LOG_FILE) 2>&1


# ---------- 记录开始时间(秒级时间戳) ----------
START_TIME=$(date +%s)
START_DISPLAY=$(date "+%Y-%m-%d %H:%M:%S")

# ---------- 写入日志头 ----------
{
  echo "============================================================"
  echo "命令执行开始: ${START_DISPLAY}"
  echo "工作目录: $(pwd)"
  echo "执行命令: $*"
  echo "============================================================"
} > "${LOG_FILE}"

echo "[INFO] 工作目录: $(pwd)"
echo "[INFO] 开始执行: $*"
echo "[INFO] 日志输出: ${LOG_FILE}"
echo "------------------------------------------------------------"

# ---------- 执行命令,同时输出到终端和日志 ----------
"$@"
EXIT_CODE=${PIPESTATUS[0]}

# ---------- 记录结束时间 ----------
END_TIME=$(date +%s)
END_DISPLAY=$(date "+%Y-%m-%d %H:%M:%S")
ELAPSED=$((END_TIME - START_TIME))

# ---------- 格式化耗时 ----------
HOURS=$((ELAPSED / 3600))
MINUTES=$(( (ELAPSED % 3600) / 60 ))
SECONDS=$((ELAPSED % 60))

if [ ${HOURS} -gt 0 ]; then
  DISPLAY_TIME="${HOURS}h ${MINUTES}m ${SECONDS}s"
elif [ ${MINUTES} -gt 0 ]; then
  DISPLAY_TIME="${MINUTES}m ${SECONDS}s"
else
  DISPLAY_TIME="${SECONDS}s"
fi

# ---------- 写入日志尾 ----------
{
  echo "============================================================"
  if [ ${EXIT_CODE} -eq 0 ]; then
    echo "命令执行成功 (退出码: ${EXIT_CODE})"
  else
    echo "命令执行失败 (退出码: ${EXIT_CODE})"
  fi
  echo "开始时间: ${START_DISPLAY}"
  echo "结束时间: ${END_DISPLAY}"
  echo "总耗时  : ${DISPLAY_TIME}"
  echo "============================================================"
} >> "${LOG_FILE}"

echo "------------------------------------------------------------"
if [ ${EXIT_CODE} -eq 0 ]; then
  echo "[INFO] 执行成功 | 耗时: ${DISPLAY_TIME}"
else
  echo "[ERROR] 执行失败 (退出码: ${EXIT_CODE}) | 耗时: ${DISPLAY_TIME}"
fi
echo "[INFO] 完整日志: ${LOG_FILE}"

exit ${EXIT_CODE}

整套系统的运转画面是这样的:工作目录下安静地躺着一个 .git_local 仓库,文件监听工具在后台盯着每一个文件的增删改,一旦有变化就触发提交脚本,变化被锁机制串行吸收进快照历史,同时写入监控日志;需要回看时,敲一下视图切换脚本,Git 的所有命令就指向快照仓库,任意回放某个时间点的文件状态;执行耗时命令时,命令执行脚本同步记录过程与结果,与文件快照相互印证。

这是一种把 Git 用在它本不擅长的场景里、却意外合手的方式。它不替代正式的版本管理,而是为那些介于正式提交之间的细碎变化提供一张安全网,让本地文件的每一次状态都有迹可循。

相关推荐
冻柠檬飞冰走茶1 小时前
PTA基础编程题目集 7-37 整数分解为若干项之和(C语言实现)
c语言·开发语言·数据结构·算法
末代iOS程序员华仔2 小时前
OPC + Flutter + Swift:跨平台应用上架与专业知识点获客实战指南
开发语言·flutter·swift
脚踏实地皮皮晨2 小时前
003004002_UniformGrid控件的使用方法
开发语言·c#·.net·wpf·visual studio
Huangjin007_3 小时前
【Linux 系统篇(十一)】基础开发工具(六) —— 版本控制器 Git
linux·运维·git
j7~3 小时前
【Linux】二十六.线程篇三《Linux多线程编程:线程控制、线程ID以及进程地址空间分布、线程局部存储__thread以及clone系统调用》---详解
linux·运维·服务器·开发语言·c++·多线程编辑·线程的控制
维吉斯蔡10 小时前
【VS Code / Cursor】文件夹右键快捷打开与文件类型自动关联
开发语言·程序人生·学习方法
luj_176812 小时前
星火科技助力边远地区防病攻坚
c语言·开发语言·c++·经验分享·算法
always_TT13 小时前
【Python 日志记录:logging 模块入门】
开发语言·python·php
xcLeigh13 小时前
Go入门:变量声明的五种方式详解
java·开发语言·golang