不提交也能同步Git未提交代码:对象快照与rsync两种方案实战
你可能也遇到过这种尴尬代码改动写了一半远没到能安心提交的程度但另一个环境急需这份数据——同事要评审、测试机要复现、换一台机器接着调等等。很多人第一反应是临时 commit 一下同步完再 reset结果不是污染了提交历史就是 reset 的时候把整理到一半的暂存区给搞乱了。其实“没 commit 也能同步数据”这件事稳定、优雅的操作是存在的而且至少有两条完全不同的路线一条基于 Git 对象模型一条干脆绕开 Git。下面我把两条路线都拆开讲连同实测中踩过的几个坑一起写清楚。1. 为什么临时同步不该带着“commit”这个包袱1.1 临时同步的真实场景“临时同步”最常见的场景是功能开发到中间状态接口调通了但代码还没整理或者前端页面改了一半要先给同事看一眼效果。这时候数据本身是“半成品”不适合进入主分支历史但你又必须把它原封不动地搬到另一台机器上。还有一种更隐蔽的场景你正在处理一批大型文件、配置文件或者构建产物这些内容可能本来就不该进 Git被 .gitignore 忽略但你需要在多台机器之间保持一致。这类数据同步本质上不是“版本管理”而是“文件分发”。非要用 commit 去完成文件分发等于把两种完全不同的需求强行揉在一起。这两种场景的共同点是你需要的是一条不改变仓库状态的数据通道而不是一次真正的版本提交。1.2 硬提交的代价先算算“临时 commit 再回退”这条路到底要付出什么代价。常规操作是git add -A git commit -m temp sync git push origin temp-sync # 同步完回到原分支 git reset --soft HEAD~1 git push origin --delete temp-sync问题出在哪第一reset之前如果别人也往这个分支上推过东西你的回退就很容易和远端冲突第二如果 commit 之后再动了几个文件原本整理好的暂存区会被打乱reset --soft并不会帮你恢复 staging 状态第三只要是共享分支这个临时 commit 哪怕只存在几分钟也可能被同事的自动构建、CI 流程抓到造成一次莫名其妙的构建记录。我在真正自己搭同步流程之前用这种“硬提交”方式折腾过好几次每次都要额外花时间清理现场。后来想明白了一件事同步和提交本来就是两回事Git 底层其实提供了更高明的机制只是很少人用。2. Git对象型方案制造一个“不占分支”的快照2.1 底层逻辑分支只是标签对象才是数据理解这个方案之前需要先纠正一个常见的认知偏差你以为 Git 存的是“提交记录”实际上 Git 存的是对象blob、tree、commit、tag 四种分支、标签都只是一串指向某个 commit 的引用ref。数据真正存在.git/objects里分支名只是方便你找到数据的临时标签。这意味着什么意味着只要你构造出了一个 commit 对象哪怕没有任何分支指向它它也真实存在于仓库里。你完全可以把这个“孤儿 commit”传给远端远端再据此取回完整数据。整个过程没有移动任何本地分支没有产生git log里能看到的新提交也就谈不上“污染历史”。2.2 手把手write-tree commit-tree push 临时引用具体怎么做假设我在 A 机器上有改动要把当前工作区状态同步到 B 机器。注意这里说的“工作区状态”包括已修改文件、已删除文件、以及我先git add过的未跟踪文件。第一步把所有改动纳入暂存区git add -A第二步把暂存区内容变成一个 tree 对象tree$(git write-tree)git write-tree做的事情是把当前索引staging area的整体目录结构序列化成一个 tree 对象并返回这个对象的哈希值。文档里说它通常给底层脚本用但恰恰是这种底层命令才让我们能绕过“提交”这个动作。第三步以当前 HEAD 为父提交构造一个 commit 对象parent$(git rev-parse HEAD) commit$(git commit-tree $tree -p $parent -m tmp snapshot $(date %F-%T))git commit-tree会创建一个 commit 对象但它不会移动 HEAD也不创建任何分支——它只是把一个包含了消息、父提交、tree 对象信息的 commit 写进对象库。这一步执行完你既看不到新提交当前的修改也原样保留。第四步把这个“孤儿 commit”推送到远端的临时引用git push origin $commit:refs/tmp/snapshotrefs/tmp/snapshot不是分支不是标签只是一个用来承载对象的临时引用名。Git 允许自定义引用路径推送到这里不会出现在git branch或git tag输出里也不会被常规的 fetch 拉到本地分支。数据就这么神不知鬼不觉地传过去了。在 B 机器上拿数据也简单git fetch origin refs/tmp/snapshot git switch -C tmp/snapshot FETCH_HEADgit switch -C tmp/snapshot的意思是创建或强制重置一个本地临时分支tmp/snapshot指向这个快照然后你的工作区会整体变成 A 机器上那个未提交的状态。看完了切回原来的分支就行git switch 你原来的分支 git branch -D tmp/snapshot整个流程走下来A 机器的分支历史一个字符都没变B 机器也从不曾出现过“临时提交”这种脏东西。2.3 进阶stash create 一条命令出快照如果你觉得 write-tree commit-tree 两条命令有点绕还有更短的方案git stash create。git add -A snap$(git stash create tmp snapshot) git push origin $snap:refs/tmp/snapshotgit stash create和平时用的git stash不一样它会基于当前工作区和 HEAD 创建一个快照 commit但不修改工作区、不往 stash 列表里存任何东西。换句话说它像“侦察兵”一样把现场拍了个照然后原样退下。这个快照对象同样可以推送到临时引用。如果工作区和 HEAD 完全一致它会输出空字符串所以更稳妥的做法还是前面 write-tree 那套至少行为完全可控。这套方案有个硬前提目标仓库和源仓库必须能互相访问同一个远程或对方能 fetch 到你的引用。如果两台机器完全隔离、网络都不通怎么办可以把对象打包成文件传过去git bundle create sync.bundle refs/tmp/snapshot把sync.bundle用任何方式传给对方对方仓库里执行git fetch sync.bundle refs/tmp/snapshot照样能取回快照。这就是对象型方案的另一层好处它和网络协议解耦只要对象能移动就行。3. 不用Git也行目录级文件同步的两种姿势对象型方案虽然优雅但它解决的是“Git 仓库内数据的同步”。如果你的数据根本不在 Git 里管理——构建产物、图片资源、配置文件、日志包——那就该换赛道了直接在文件系统层做同步。3.1 rsync单次镜像同步rsync 是这一类工具里最成熟的。它的核心强项是增量传输和校验第一次全量后面只传差异块。我常用的命令是rsync -avz --delete ./src/ userhost:/path/to/dest/几个参数必须解释清楚因为它们直接决定同步结果对不对-a是归档模式等价于递归传输并保留权限、时间戳、软链接等属性-v显示传输明细-z传输时压缩--delete表示远端删除源端已经不存在的内容让两端最终完全一致。这里还有一个极其容易踩的坑源目录结尾的斜杠。rsync -av ./src/ host:/dest/是把src目录下的内容复制到 dest 里而rsync -av ./src host:/dest/会把src这个目录本身复制到 dest 下面变成dest/src/。执行之前如果不明白这个区别同步完的目录结构会直接乱掉。任何同步操作前先跑一遍演练rsync -av --delete --dry-run ./src/ userhost:/path/to/dest/--dry-run只打印将会执行的操作不真正传输。这个习惯能救很多命——尤其是--delete在场的时候。3.2 inotifywait rsync实时监听同步单次 rsync 适合手动触发但“同步”这个词本身暗示着持续进行。要实时就得上文件系统监听。Linux 上最直接的方式是用 inotify 工具等事件事件来了再调 rsync。一个极简的监听同步脚本长这样#!/usr/bin/env bash while true; do inotifywait -q -r -e modify,create,delete,move ./src rsync -avz --delete ./src/ userhost:/path/to/dest/ doneinotifywait会阻塞等待指定事件一旦./src下有任一文件被修改、新建、删除或移动它立刻返回然后脚本执行一次 rsync。为什么不在每个事件里立刻同步因为一次批量修改可能触发几十个事件逐个同步会重复传输让 rsync 在事件风暴过后统一收敛一轮反而更省。生产环境不建议裸用这种循环因为内核 inotify 队列可能被大量事件挤爆导致脚本退出。更稳妥的做法是用带超时模式的监听让循环每隔一段时间强制同步一次while true; do inotifywait -q -r -t 30 -e modify,create,delete,move ./src rsync -avz --delete ./src/ userhost:/path/to/dest/ done加上-t 30后即使 30 秒内没有事件inotifywait 也会因为超时返回rsync 照常跑一轮。这样即便偶发丢事件数据最终也会在下一个周期补上。3.3 双向同步的坑注意上面所有 rsync 写法都是单向的——源文件夹向目标文件夹单向收敛。如果你希望两台机器互相改了之后都能同步过去那这叫双向同步复杂度直接上一个台阶。双向同步最大的问题是删除冲突A 机器删了一个文件B 机器也改了另一个文件两端各自基于不同的“上一版状态”发生了分歧单向 rsync 一跑必有一方被覆盖。我在实际使用里发现很多团队把单向 rsync 用在“相对固定角色”的环境开发机同步到服务器、构建机同步到发布目录是完全没问题的但如果你想在两台都频繁改动的电脑之间保持同一份工作目录那就别用纯 rsync老老实实回到 Git 对象型方案或者用支持冲突检测的双向同步工具否则一定会出现“我的新文件被覆盖了”的惨案。4. 对象型同步和目录型同步到底怎么选把两类方案摆在一起对比选型的时候心里才有底对比维度Git 对象型快照同步目录级 rsync 同步是否污染提交历史不污染无新增 commit 记录完全不涉及 Git目标端是否需要 Git 仓库需要不需要能同步的内容受 Git 管控的文件含先 add 的未跟踪文件任意文件包括被 .gitignore 忽略的部分是否需要安装额外工具仅依赖 Git需要 rsync、inotifywait 等是否支持增量Git 对象按需拉取压缩传输rsync 增量块传输对删除文件的处理切到快照分支后整体覆盖可准确对应--delete可镜像删除实时性手动触发或脚本触发可配合 inotify 实时同步适合场景代码快照、评审评审、跨机继续开发构建产物、资源文件、配置文件分发我的选择标准很简单如果数据本来就在 Git 仓库里优先用对象型如果数据根本不该进 Git用目录型。对象型方案最大的价值是它完全尊重版本管理边界——不产生垃圾提交也不丢失“未提交”状态本身目录型方案最大的价值则是它彻底绕开了 Git 对文件类型的限制。两者不是替代关系而是互补关系。一个干净的工作流可以同时用两套代码用对象快照同步构建产物用 rsync 镜像。各管各的互不干扰。5. 实测记录两个目录模拟跨机同步的完整过程只看命令不跑一遍永远不知道里面有多少暗坑。我在自己机器上做了完整复现用两个目录模拟 A/B 两台机器A 目录作为“源仓库”B 目录作为“目标仓库”。5.1 环境准备和命令回放先准备一个裸仓库作为公共远程然后分别克隆出 A 和 Bgit init --bare remote.git git clone remote.git machine_a git clone remote.git machine_b在 machine_a 里创建两个文件一个修改已跟踪文件一个新建未跟踪文件cd machine_a echo v1 tracked.txt git add tracked.txt git commit -m init echo v2 tracked.txt # 修改但未提交 echo brand new untracked.txt # 未跟踪然后执行快照推送git add -A tree$(git write-tree) parent$(git rev-parse HEAD) commit$(git commit-tree $tree -p $parent -m tmp snapshot) git push origin $commit:refs/tmp/snapshot回到 machine_b 里取数据git fetch origin refs/tmp/snapshot git switch -C tmp/snapshot FETCH_HEAD ls # 能同时看到 tracked.txt 的 v2 和 untracked.txt实测结果是B 机器的工作区确实变成了 A 机器未提交时的完整状态git log里没有新增任何提交记录A 机器的git status也保持着原先的“有修改、有未跟踪文件”的状态。注意我在 A 里面先执行了git add -A这是刻意为之。write-tree是从暂存区生成 tree 对象的未跟踪文件如果没有先加进暂存区就不会进入快照。这一步是“要不要同步未跟踪文件”的控制开关。5.2 翻车1-4复盘第一次跑这套流程时我没有先git add -A结果快照推过去后对方拿到的内容里缺少 untracked.txt我还以为自己命令写错了最后才发现是暂存区根本没包含那个文件。这个坑出现概率极高因为大多数人写代码时对“未跟踪文件”是无感的。第二次翻车是推送临时引用后忘了清理。refs/tmp/snapshot在远端就这么躺着下次再推虽然会覆盖但如果项目人多别人也会偶尔 fetch 到这个引用造成困惑。正确的收尾是传完就删git push origin :refs/tmp/snapshot第三次翻车是我在 B 机器上尝试用git checkout FETCH_HEAD -- .想把改动“铺”到原分支工作区结果发现 A 机器上删除的文件并没有从 B 机器工作区消失。原因是 checkout 指定路径时只覆盖已有路径不会删除目标方多出来的文件。要得到精确一致的目录状态最省心的还是git switch -C tmp/snapshot FETCH_HEAD切换到一个临时分支让工作区彻底对应到快照内容。看完再切走干干净净。第四次翻车发生在 rsync 侧。我第一次同步两个目录时自信地加了--delete结果把目标目录里一些独立维护的临时文件全删了。从那以后我给自己立了条规矩任何带 --delete 的同步至少先跑五次 dry-run确认输出里每一项删除都是预期内的。如果目录里确实有不需要双向一致的子目录用 rsync 排除规则把它挡在外面rsync -avz --delete --exclude.cache/ --exclude*.log ./src/ userhost:/dest/6. 两个可以直接抄走的同步脚本结合上面的实测经验我整理了两个顺手脚本日常用起来比手敲一长串命令要舒心得多。6.1 send-snapshot.sh源端发送#!/usr/bin/env bash set -euo pipefail REMOTE${1:-origin} REFrefs/tmp/snapshot TAG$(date %F-%T) git add -A TREE$(git write-tree) PARENT$(git rev-parse HEAD) SNAP$(git commit-tree $TREE -p $PARENT -m tmp snapshot $TAG) git push $REMOTE $SNAP:$REF echo snapshot pushed: $SNAP6.2 apply-snapshot.sh目标端接收#!/usr/bin/env bash set -euo pipefail REMOTE${1:-origin} REFrefs/tmp/snapshot BRANCHtmp/snapshot git fetch $REMOTE $REF git switch -C $BRANCH FETCH_HEAD echo working tree is now at snapshot, switch back when done接收端用完清理git switch 你的分支名 git branch -D tmp/snapshot git push origin :refs/tmp/snapshot6.3 使用建议把发送脚本放到用了很久的工作流里之后我的习惯是需要临时给数据时直接跑./send-snapshot.sh对方跑./apply-snapshot.sh完事当天顺手删掉临时引用。这条链路完全取代了过去的“临时 commit reset”特别适合频繁需要评审中间态代码的场景。如果同步的是仓库外的资源文件那就走 rsync 加监听的路子如果是 Git 仓库里的代码我强烈建议用对象型快照。两者都试过之后我个人的体会是同步数据最怕的不是技术难而是把临时需求和正式机制混在一起。给临时的同步需求安排一条独立的临时通道不给正式分支添任何乱——这个思路本身比某个具体命令值钱得多。