Git 负责记录版本,GitHub 则提供仓库托管、问题讨论和代码审查。知道如何使用它们是必备的技能。
把代码推上去只是其中一步,后面还会遇到分支怎么分、提交怎么拆、什么时候合并,以及怎样自动检查和发布。
这里,本文将按照进行一次改动并提交到项目中的过程,把这些事情整理一下。

# 整理仓库

# README 应该写些什么

打开一个项目,首先需要知道它是做什么的、现在做到了什么程度,以及怎样运行。README 需要先把这几件事说清楚,再提供详细文档的入口。

比较实用的顺序是:项目介绍、效果展示、主要功能、安装或运行方法,最后放贡献方式和许可说明。有界面的项目附一张实际截图,会比一长串功能名称直观。环境要求和启动命令也要留全,让读者能够跟着指导安装依赖并运行项目。

以我的一个仓库为例(DungeonTavern),开头先说明项目内容并提供文档入口,接着用场景图展示实际界面,再介绍已有功能。需要运行项目时,可以继续查看后面的启动说明。README 不必把所有设计细节都塞进去,长篇说明可以单独放到文档里,再从这里链接过去。仓库内的文档和图片建议使用相对路径,切换分支或下载源码后也比较方便。

DungeonTavern README 中的项目介绍、文档入口与场景展示

# 说明使用和贡献方式

许可证(LICENSE)用来说明项目允许怎样使用;
CONTRIBUTING.md 则说明如何准备环境、验证改动和提交贡献。
这两者可以从 README 提供入口,方便使用者和贡献者查找。

如果希望提交问题和 PR 时提供相同的信息,可以再添加 Issue、PR 模板。
例如,Issue 模板可以要求填写复现步骤,PR 模板可以要求说明改动和验证结果。

# 排除不需要提交的文件

.gitignore 用来排除不需要跟踪的文件。比如 Node.js 应用中常见的依赖目录、构建结果和本地环境配置:

node_modules/
dist/
.env
.env.*
!.env.example

这里的 .env.example 为 .env 的示例文档,应当只保留变量名称和示例值,实际密码或令牌不要填进去。 dist/ 是否忽略则取决于项目如何分发,有些仓库确实需要跟踪构建结果,不能直接套用。

已经被 Git 跟踪的文件,不会因为后来加入 .gitignore 就停止跟踪。如果确认其需要移出版本管理,并保留本地文件,可以执行:

git rm --cached .env

这个命令不会清除历史提交里的内容。如果曾经提交了密钥,仍然需要撤销或轮换密钥,不能只删除当前文件。

# 为改动创建分支

开始修改前,先确认工作区没有未处理的内容,再从更新后的主分支创建工作分支。下面假设主分支叫 main ,远端叫 origin :

git status
git switch main
git pull --ff-only origin main
git switch -c fix/category-wrap

--ff-only 只允许快进更新。如果本地和远端已经各自产生了提交,它会停下来,方便先看清分歧再处理。

分支名称能说明改动目的就可以,例如 fix/category-wrap 、 feat/search 。
一次工作尽量围绕一个问题展开,如果修复分类显示时顺手改了发布脚本,最好把此更改拆出去。这样审查、撤销和查找历史都会容易一些。

对于持续维护主分支的小项目,短期工作分支通常已经够用;需要同时维护多个版本时,再考虑版本分支。

# 同步主分支的改动

工作还没完成,主分支可能已经更新。同步前先提交本地的当前修改,或用 git stash 临时保存,确认工作区和暂存区干净后,再获取远端信息:

git fetch origin

然后根据项目约定,选择 merge 或 rebase。下面两条是不同的处理方式,不要依次执行:

# 在当前工作分支中合入主分支
git merge origin/main
# 或者,把当前工作分支的提交重放到新的主分支之后
git rebase origin/main

merge 保留原有提交;分支已经分叉时,会通过合并提交连接两段历史。rebase 则把自己的改动逐个应用到新基点上,对应提交的标识会改变。

选择时主要看两件事:是否需要保留已有提交,以及这些提交有没有被其他人使用。

当前情况 建议方式 原因或限制
多人共同使用工作分支,或别人已经基于这些提交继续开发 优先 merge 保留已有提交标识,协作者不需要因为同步主分支而重新处理自己的历史
工作分支只在本地,想把改动整理到最新主分支之后 可以 rebase 提交尚未共享,改写不会影响其他人的分支
工作分支已推送,但只有自己使用 按仓库约定选择 rebase 会改写已推送提交,后续更新远端通常需要强推;确认没有其他人依赖原提交后再使用
目标分支要求线性历史 不能向它推送 merge commit 可以选择符合规则的 rebase 或 squash;快进合并不会产生 merge commit,不受这一条限制

merge 的限制,是仓库约定不允许产生合并提交。rebase 的主要限制则是改写历史:没有与协作者协调好时,不要对共享分支执行,也不要为了同步就重写公共主分支。

后面选择 PR 合并方式这一节中,也涉及 merge 和 rebase,两处使用的原理相通,但操作的阶段和目标不同。

Merge 与 Rebase 同步后的提交关系示意

遇到冲突时,先修改冲突文件,再用 git add 标记已解决。
修复冲突后,merge 可以继续提交;rebase 使用 git rebase --continue 。
如果想回到本次操作之前,分别使用 git merge --abort 或 git rebase --abort 。

如果前面使用了 git stash ,同步完成后再用 git stash pop ,就能恢复本地的修改。用 stash 恢复时也可能需要解决冲突。
恢复完成后,最好要运行相关检查,因为文本能合并不代表程序行为一定正确。

# 把改动整理成提交

提交之前,先看自己到底改了什么:

git status
git diff
git add -p
git diff --cached

git diff 查看尚未暂存的修改; git diff --cached 查看将要提交的内容。 git add -p 可以按修改块暂存,同一个文件里有两项无关改动时也能分开处理。新建文件则根据 git status 的结果单独添加。

一次提交可以包含实现、测试和必要文档,只要它们共同完成同一件事。不需要按文件机械拆分,也不要把调试文件、批量格式化和实际功能混成一包,否则进行回退等操作就会非常麻烦。

面向公开协作的项目,提交说明用英文更方便不同语言的贡献者阅读;如果仓库已有语言约定,就与现有提交保持一致。英文不是 Git 的要求。

提交说明的第一行直接说改了什么,需要解释原因时再补正文。例如:

Fix wrapping of long category titles on mobile
Allow titles to wrap within the text column instead of moving
below the date.

确认暂存内容后,执行 git commit ,在编辑器中填写提交说明。

相比 update 或 fix bug ,这样的说明在历史列表里就能看懂。Git 项目的贡献规范也强调按逻辑组织改动,并交代修改的理由。

feat: 、 fix: 这类前缀可以作为团队约定,也方便配合自动生成版本说明,但不是所有项目都必须使用。重要的是含义明确、同一仓库保持一致。


如果刚提交完发现漏了相关修改,而且提交还没有共享,可以补入暂存区后执行:

git commit --amend

这会替换最近一次提交。已经被别人使用的提交,不要仅为了整理说明就随意改写,补一个后续提交往往更省事。

# 提交 PR 并完成合并

改动准备好后,推送工作分支:

git push -u origin fix/category-wrap

在 GitHub 创建 Pull Request,确认目标分支和来源分支,再写清楚问题、改动和验证结果。比如:

## 改动
修复手机端分类长标题换行后挤开日期的问题。
## 验证
- 检查长标题和短标题的排列。
- 检查桌面与窄屏下的分类跳转。
Closes #42

这是说明格式的示例,验证项要按实际完成的检查填写。使用 Closes #42 关联并关闭 Issue 时,也要确认编号对应本次解决的问题。

还没完成但希望先讨论方向,可以创建 Draft PR。
审查时除了代码本身,也应关注是否改变了原有行为、边界情况有没有覆盖,以及说明中的检查是否真实完成。
如果还需要修改,应该继续推送到同一个工作分支,PR 会更新,不必为每轮修改重新开一个。

VS Code 的贡献指南要求 PR 聚焦问题、避免无关格式化。这一点很值得借鉴:改动越集中,审查者越容易判断它是否正确。

# 选择合并方式

前面同步主分支的改动,是在开发过程中更新工作分支。PR 审查完成后,这里决定的是如何把工作分支的结果加入主分支。

本地使用过 rebase,不代表 PR 也必须选择 Rebase and merge;本地通过 merge 同步过主分支,PR 最后也可以 squash。同步方式负责让开发基于最新代码,PR 合并方式决定主分支最终保留怎样的历史。

GitHub 常见的三种合并方式如下:

方式 合并后的历史 适合的情况
Create a merge commit 保留分支提交,并增加合并提交 希望保留完整分支关系
Squash and merge 将 PR 改动合成一个新提交 中间提交主要是修修补补,整体是一项改动
Rebase and merge 将 PR 提交逐个加入目标分支,不增加合并提交 提交本身已经整理好,希望保留每一步

GitHub 的 Rebase and merge 会为各个提交生成新的标识;Create a merge commit 则保留原提交,另外增加一个合并提交。选择哪一种方式,按仓库约定保持一致即可。

# 合并后的分支清理

确认合并后,可以删除 GitHub 上的工作分支,并更新本地主分支。

这里详细说一下 Squash and merge 和 Create a merge commit 的区别。
squash 会把改动汇总成一个新提交,例如,工作分支有 A、B、C 三次提交,主分支得到的是新的 S,历史中并没有直接包含 A、B、C;而使用 GitHub 的 Create a merge commit 时,主分支会保留 A、B、C,并新增合并提交 M。

使用 git branch -d 删除本地分支时,Git 会先检查它是否已合入所跟踪的上游分支;没有设置上游时,则检查是否已合入当前分支。这个判断依据提交的祖先关系,不比较改动内容。
因此,squash 后即使代码已经进入主分支,删除本地分支时仍可能提示 not fully merged 。

所以,遇到这种提示时,要先核对 PR 是否已经合并,并确认工作分支在此后没有新增未合入的提交。
确认分支中的工作都已保留后,再删除本地分支:

git switch main
git branch -D fix/category-wrap

这里的 -D 跳过合并状态检查,删除的是本地分支引用,不会撤销主分支上的 squash 提交。
(注意区分 -d 和 -D )

# 用 GitHub Actions 自动检查和发布

PR 中反复要做的构建和测试,可以交给 GitHub Actions。这样每次更新代码后都有检查结果,也方便审查者查看失败原因。

# CI/CD

CI/CD 描述的是代码从集成到交付的开发流程,GitHub Actions 则是实现这些流程的自动化平台。

  • CI(Continuous Integration,持续集成):频繁集成代码,并通过自动构建、测试等检查尽早发现问题。比如,每次提交 PR 都检查能否构建、测试是否通过。
  • 持续交付(Continuous Delivery):在检查之后继续完成打包等准备,让版本保持可以发布的状态;是否正式发布,可以由人决定。
  • 持续部署(Continuous Deployment):将通过检查的改动自动部署到生产环境,流程中不再保留人工批准发布这一步。

它们不是某条固定的命令,也不是几个特定的 action。实际执行的 npm test 、构建、上传产物和部署命令,才是组成这些流程的具体步骤。

在 Actions 中,把步骤组织成 workflow,再指定何时触发,就能实现相应的 CI/CD 流程。当然 Actions 也可以用于自动添加标签等仓库管理任务,范围不限于 CI/CD。

# 添加检查工作流

工作流文件放在 .github/workflows/ 下。一个 workflow 可以由 PR、推送或手动操作等事件触发,里面包含一个或多个 job;每个 job 在 runner 上执行,其中的 step 按顺序运行命令或调用 action。不同 job 可以通过 needs 设置依赖。

下面以 Node.js 应用为例。
假设项目使用 Node.js 24,已经提交 package-lock.json ,并且 package.json 中有可在 CI 环境运行的 test 和 build 脚本。
创建 .github/workflows/ci.yml :

name: CI
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch:
permissions:
  contents: read
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run build

这个配置只负责检查,不会发布应用。PR 分支更新时会执行,合入 main 后也会检查; workflow_dispatch 提供手动触发入口,工作流需先存在于默认分支才能使用这个入口。

npm ci 按锁文件安装依赖;没有测试脚本时,将此处替换为项目实际使用的检查命令。
需要严格固定依赖时,可以将 action 固定到经过核对的完整提交 SHA。

进入仓库的 Actions 页面,可以按一次运行查看 job 和 step 日志。排查时先找到失败的步骤,再看报错前后的上下文,确定是安装依赖、测试还是构建出了问题。

下图是我的 OneDrop 项目中,一次实际 CI 运行的部分步骤列表,包含格式检查、TypeScript 编译、测试和两个构建目标。它比上面的最小示例多了项目自身需要的检查。

OneDrop CI 成功运行的任务步骤

# 缓存、产物与发布

上述示例中的 cache: npm ,用来复用 npm 的依赖缓存,减少重复下载,仍然需要执行 npm ci 。它不会直接缓存 node_modules 。

构建出来的安装包、测试报告则属于 artifact,上传后可以供后续任务使用或下载检查。缓存用于加快执行,产物用于保留执行结果,两者不要混用。

发布可以另建一个工作流,根据项目选择 tag 推送、Release 发布或手动操作作为入口。
需要人工确认时,可以通过部署环境的保护规则设置审批;具体能力取决于仓库类型和所用方案。

检查工作流只需要读取代码。因此,上述 CI 示例中将权限设为 contents: read 。只有确实需要写入 Release 或部署的任务才添加相应权限。令牌等敏感配置放到 Secrets,避免写进 YAML。

来自外部 Fork 的 PR 默认受令牌和 Secrets 限制,不应为了让它拿到发布权限就改成高权限执行。尤其不要在 pull_request_target 这类拥有目标仓库权限的流程中,直接检出并执行不受信任的 PR 代码。

# 保护主分支

流程确定之后,可以通过仓库规则减少误操作。经典的分支保护规则和 Rulesets 都能约束分支;Rulesets 可以组合多组规则,也能与现有分支保护共同生效。

下面按 GitHub Rulesets 中的英文名称说明常用规则;经典分支保护页面的名称和选项组织方式略有不同。

规则或选项 用途
Require a pull request before merging 要求改动通过 PR 合入目标分支
Require status checks to pass 要求指定的状态检查通过,例如前面的 CI
Required approvals 位于 PR 规则下,设置合并前需要的批准数量
Block force pushes 阻止强制推送,保护已有提交历史
Restrict deletions 限制删除受保护的分支

如果还希望主分支不出现 merge commit,可以启用 Require linear history。这时 PR 应使用 squash 或 rebase 合并,与前面的合并方式选择对应。规则的具体含义可查阅 GitHub Rulesets 文档。

设置状态检查时,先让对应工作流实际运行一次,再选择正确的检查名称。
如果检查被路径过滤等条件跳过,还要确认它不会让 PR 一直等待。不要要求一个实际上不会出现的检查。

个人项目可以保留 PR 和 CI 相关的保护规则,不用设置无法完成的审查人数。
多人项目再按需要增加审批或代码负责人审查。
还要结合仓库设置确认功能的可用范围和允许绕过规则的权限,才能判断哪些推送会受到限制。

# 通过 Fork 参与其他项目

有仓库写入权限时,可以直接推送工作分支;没有权限时,通常先 Fork 到自己的账号,再在自己的仓库里修改并提交 PR。
Fork 是 GitHub 上的仓库关系,clone 则是把仓库下载到本地,两者不是同一件事。

clone 自己的 Fork 后, origin 通常指向自己的仓库。为了获取原项目的更新,还需要添加一个指向原项目的远端,这里将它命名为 upstream 。 origin 和 upstream 都是远端名称,不是 Git 强制规定的角色。

下面 URL 中的 OWNER 、 REPOSITORY ,分别替换为原项目的所有者名称和仓库名称,不要填成自己的 Fork。若已经配置过 upstream ,检查地址正确后即可跳过添加命令。

git remote -v
git remote add upstream https://github.com/OWNER/REPOSITORY.git

接下来同步主分支。这里采用的方式是: main 只跟随原项目,自己的修改放在工作分支中。先处理未提交修改,再依次同步自己的远端和原项目:

git fetch origin
git fetch upstream
git switch main
git merge --ff-only origin/main
git merge --ff-only upstream/main
git push origin main
git switch -c fix/category-wrap

两次 --ff-only 都要求当前分支能够直接向前更新。如果本地 main 、Fork 的 main 或原项目主分支之间存在各自独有的提交,操作就可能停止。此时先查看分歧中的提交,决定保留哪些改动,再按前面介绍的 merge/rebase 方式处理;强推会改写远端历史,不能用来跳过这个判断。

同步完成后,在新建的工作分支中修改,推送到自己 Fork 的 origin ,再向原项目要求的目标分支创建 PR。

Fork 协作中的本地仓库、origin 与 upstream

提交前先看原项目的贡献说明,确认目标分支、测试要求以及是否需要先讨论 Issue。
自己的习惯可以调整,但项目已经约定的流程需要遵守。

# 用 Git LFS 管理大文件

源文件中的大图片、模型或音频,可能需要和代码一起维护,但不适合让普通 Git 历史不断积累完整二进制版本。
Git LFS 会让 Git 仓库记录较小的指针文件,实际内容存放在 LFS 存储中,使用时再获取。
GitHub 会拒绝推送超过 100 MiB 的普通 Git 文件;需要跟踪这类文件时,可以使用 Git LFS。

使用 Git LFS 之前,先要区分文件的用途:开发必需、需要跟踪版本的二进制资源可以考虑 LFS;构建缓存通常忽略;供用户下载的安装包则更适合放到 Release。
并不是所有体积大的文件都要加入 LFS。

安装 Git LFS 后,在仓库中初始化并添加规则。下面以尚未被普通 Git 跟踪的 PSD 文件为例:

git lfs install
git lfs track "*.psd"
git add .gitattributes
git add assets/example.psd
git commit -m "Track PSD assets with Git LFS"
git lfs ls-files

git lfs track 将规则写入 .gitattributes ,这个文件也要一起提交,协作者才能使用相同的规则。
通过 git lfs ls-files 检查目标文件是否已经由 LFS 管理,再继续推送提交。

项目的协作者也需要安装 Git LFS。
如果拿到的文件只有几行指针文本,可以在仓库里执行 git lfs pull 获取实际内容。

如果给已经提交过的大文件补一条跟踪规则,不会自动缩小旧历史。
历史迁移会涉及提交改写和协作者同步,应单独备份、确认范围后处理,不要为了清理体积直接在共享仓库里重写。

LFS 的存储和下载额度也需要留意,选择前查看账号当前的计费规则即可。

# 标记版本

tag 用来标记某个版本位置。确认当前提交就是要发布的版本后,可以创建附注标签并单独推送:

git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0

GitHub Release 基于 tag,再附上版本说明和可下载的文件。
版本说明适合写用户能感知的变化、修复的问题和必要的升级事项,安装包则作为附件提供。
Release 自动生成的源码压缩包不等于已经构建好的程序。

# 撤销改动

撤销前先执行 git status 和 git diff ,确认需要保留哪些内容。下面几个动作的影响并不相同:

情况 操作 结果
文件已暂存,想重新选择提交内容 git restore --staged path/to/file 取消暂存,保留工作区修改
确认不要某个已跟踪文件尚未暂存的修改 git restore path/to/file 用暂存区版本恢复工作区,丢弃这部分修改
最近一次本地提交尚未共享,想重新组织 git reset --soft HEAD~1 撤回提交,保留修改在暂存区
已经合入公共分支,需要撤销一项普通提交 git revert COMMIT 新增一个反向提交,保留原历史

reset 不同选项的影响不同:默认的 --mixed 会同时重置暂存区,但保留工作区; --hard 还会覆盖工作区中的相关修改,使用前需要确认这些修改是否要保留。
revert 也可能产生冲突,撤销合并提交还涉及主线选择,需要单独判断。

公共分支上的修复和撤销同样可以通过工作分支、PR 和检查完成。