git hook

Posted on By ᵇᵒ

Prologue

最近入职成都斐讯,公司于去年底战略提升与上海并列为双总部,公司和部门处于初建,人员扩充,百业待兴。

老大安排协同HR部门建立一个公司内部新人指引网站,其实本来想去组建后台服务器 搞持续集成这些,不过大局为重 一切听从领导安排。

Architecture

参考了深圳研发中心的网站,决定使用 mkdocs 框架组建一个静态网站,简单地说就是通过 mkdocs将 markdown 转化成 html,再将静态的 html 置放在服务器上 nginx 指定的目录。

mkdocs 是一个静态网站编译框架,和 Jekyll 比较类似,但在定制化的灵活性上稍差点,github上的 star 也差了一个数量级。 至于为什么不用 Jekyll ,额… 由于体检报告结果延迟的关系 我入职晚了两天:smile:,一开始是由另外的同事参与决定的。

mkdocs 调整 theme,修改 css(这一块主要由一个前端程序员妹子在负责),编写 markdown,申请服务器,申请公司二级域名,一切井然有序,一个星期左右就已经基本完成了。

这个时候发现了个问题,部门组建好步入正轨后我们将会投入到正式的工作中,估计不会有太多的时间抽出来管理网站,后续的维护得移交给HR,所有的操作得尽量简单化,于是新需求自动编译来了。

最开始的方案是决定跟我们部门的代码服务器放在一起管理,使用jenkins自动编译部署,但是老大提了要求,不能和我们的部门代码服务器混在一起。没办法,只好凑合在网站服务器上。网站服务器的配置比较低(最开始的需求也仅仅是存放一个静态网站),不适合安装gitlab-ci和jenkins这些,另外 仅仅这么一个网站项目也没必要杀鸡上牛刀。
参考了下jenkins持续集成,想到了两种方法:

  • 轮询式,写个脚本周期性去查询源码是否有更新,如果有再执行编译部署脚本。这种方式的缺点就是更新相对不及时,而且耗费服务器性能。
  • git hook,监听 git 提交执行编译部署脚本。这种方式完美地避开了轮询式的缺点,而且项目本身也使用git做版本管理。

Git init –bare

在服务器上创建git仓库

ssh user@host   // ssh登录服务器,user为服务器用户名,host为该服务器的ip
cd ~            // 进入user根目录
mkdir xxx.git   // 创建git仓库文件夹,xxx为git仓库名字
cd xxx.git      // 进入git 库文件夹
git init --bare // 建立一个不带work tree的git仓库

在HR的电脑上clone git仓库

git clone ssh://user@host/~/xxx.git

在服务器上clone git仓库,方法同上,用于管理源码进行编译。这里我选择了nginx配置的server同一级目录(/var/www/site)。

cd /var/www
git clone ssh://user@host/~/xxx.git

在服务器上clone和在HR电脑上clone一样,如果不想输入密码都必须添加ssh key到服务器(尽管服务器上clone的仍是服务器自己的git仓库:smile:),具体添加方法参考之前的ssh文章。

于是现在服务器上有了仅用于保存的git bare中心仓库(~/xxx.git),也有了clone的用于实际编译部署的git仓库(/var/www/xxx)。我们在HR电脑上(或者其他电脑上)clone中心仓库代码进行修改提交,然后中心仓库的钩子调用脚本对服务器的克隆仓库代码进行更行、编译、部署。

看懂了整个流程的同学估计有问题了,我tm为啥不直接使用中心仓库的代码进行编译部署,还非得在同一台服务器上clone一个仓库更新代码,再用这个仓库编译部署?
这就涉及到 git init 的bare参数了,加了 –bare的git init创建的仓库没有工作区,无法直接使用源码。 那么问题又来了,使用不带–bare参数的git init建一个带工作区的仓库不就行了吗?
行是可行的,但是会引入一些其他问题,比如第一次提交的时候可能会出错:

Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 332 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
remote: error: refusing to update checked out branch: refs/heads/master
remote: error: By default, updating the current branch in a non-bare repository
remote: error: is denied, because it will make the index and work tree inconsistent
remote: error: with what you pushed, and will require 'git reset --hard' to match
remote: error: the work tree to HEAD.
remote: error:
remote: error: You can set 'receive.denyCurrentBranch' configuration variable to
remote: error: 'ignore' or 'warn' in the remote repository to allow pushing into
remote: error: its current branch; however, this is not recommended unless you
remote: error: arranged to update its work tree to match what you pushed in some
remote: error: other way.
remote: error:
remote: error: To squelch this message and still keep the default behaviour, set
remote: error: 'receive.denyCurrentBranch' configuration variable to 'refuse'.
To ssh://user@host/~/xxx/.git
 ! [remote rejected] master -> master (branch is currently checked out)
error: failed to push some refs to 'ssh://user@host/~/xxx/.git'

出现这个错误是因为服务器上仓库的work tree目前也处于提交的这个branch,当然我们也可以按照提示在服务器设置git config receive.denyCurrentBranch,或者手动修改 .git/config文件添加

[receive]
denyCurrentBranch = ignore

这样设置后固然是不会再报错了,但是服务器端仓库的work tree还是保持之前的内容,必须要使用git reset --hard来匹配别人push后的内容。

git init创建的仓库,既承担着中心仓库的管理角色(git其实无所谓中心仓库,理解这里的意思就好),也承担着用于编译部署的源码work tree,两者混在一起,管理相对麻烦。既然这样,干嘛不用–bare参数创建一个裸的中心仓库,再在服务器上另外clone一个仓库管理源码用于编译部署呢?

git hook

上面已经将整个流程说清楚了,但是最重要的一点没有提到,那就是hook。
在HR的电脑上修改了源码并提交到中心仓库后,如何做到服务器的克隆仓库自动去中心仓库更新代码然后编译部署?

在每一个git仓库下面都存在一个.git文件夹,.git文件夹下面有一个hooks文件夹(使用–bare参数创建的git仓库没有.git文件夹,hooks文件夹直接在根目录下),里面有一些默认的hook脚本sample。我们可以参照着在这里编写脚本,监听git提交,执行相应的操作。当然这个hook脚本必须得写在接受git提交的那个仓库里面,毕竟 只有在那里才能监听git提交。

登录服务器,找到中心仓库的hooks文件夹:

ssh user@host           // ssh登录服务器,user为服务器用户名,host为该服务器的ip
cd ~/xxx.git/hooks      // 进入中心仓库的hooks文件夹
touch post-receive      // 创建一个名叫post-receive的脚本
chmod +x post-receive   // 给新建的脚本添加执行权限
vim post-receive        // vim打开编辑post-receive脚本

post-receive脚本内容:

#!/bin/sh
export PATH=/home/user/.local/bin:$PATH
unset GIT_DIR
DeployPath="/var/www/xxx"
cd $DeployPath
git fetch --all
git reset --hard origin/master
mkdocs build --clean
# sleep 15
rm $DeployPath/site/index.html
cp /var/www/site/index.html $DeployPath/site/index.html
rm -rf /var/www/site.bak
mv /var/www/site /var/www/site.bak
mv $DeployPath/site /var/www

依次解释下这个脚本:

  • 第一行 #!/bin/sh
    #! 这玩意儿正式名字叫 Shebang,描述该脚本用什么shell来解释执行。
  • 第二行 export PATH=/home/user/.local/bin:$PATH
    配置临时环境变量,脚本下文用到的mkdocs命令处在该path下面,不配置的话会出现 mkdocs command not found 类似的错误。
  • 第三行 unset GIT_DIR
    为后续的git命令取消默认的git dir设置,不取消的话会出现 Not a git repository: '.' 错误。
  • 第四行 DeployPath="/var/www/xxx"
    定义一个部署路径的变量,该路径即服务器的克隆仓库地址,xxx为仓库名。
  • 第五行 cd $DeployPath
    进入到服务器的克隆仓库。
  • 第六行 git fetch --all
    第七行 git reset --hard origin/maste
    这两行主要是服务器克隆仓库拉取中心仓库的更新。也可以使用git pull,但最好在pull之前进行保存git stash,编译部署结束后再git stash pop
  • 第八行 mkdocs build --clean
    对更新后的克隆仓库代码使用mkdocs进行编译。
  • 第九行 sleep 15
    休眠15秒,等待mkdocs build完成。因为脚本会自动等待build完成才继续运行,所以注释掉了该行。
  • 第十行 rm $DeployPath/site/index.html
    第十一行 cp /var/www/site/index.html $DeployPath/site/index.html
    这两行是用已经部署好的site首页替换掉编译好的site首页(先删除、后拷贝),做这个操作是由于我们公司这个项目网站特殊需求的原因:markdown编译出来的首页满足不了HR的审美需求,前端妹子手动写了个首页,每次编译好site后都用手写的首页替换掉(这个手写的首页第一次就手动添加到了nginx上的site里,替换的时候使用这个),然后再用编译的site替换掉nginx上的site。
  • 第十二行 rm -rf /var/www/site.bak
    删除老的备份。
  • 第十三行 mv /var/www/site /var/www/site.bak
    以重命名的方式备份当前nginx正式使用的site。
  • 第十四行 mv $DeployPath/site /var/www
    将编译的site移动到nginx配置的正式路径。

git script for windows

由于整个项目使用git管理,HR不会使用git命令,于是又写了两个脚本(一种替代方案是教她们使用带UI管理的git工具):

  • 下载项目
    @echo off
    echo 此脚本用于下载新员工入职培训网站项目源码
    echo 运行前请确保电脑安装有git,git下载地址:https://git-scm.com/downloads
    
    echo.
    set /p var="是否开始下载?(yes/no)"
    if %VAR%==no GOTO DIE
    
    echo.
    echo 下载开始...
    git clone ssh://user@host/~/xxx.git
    echo 下载结束!
    pause
    GOTO END
    
    :DIE
      echo.
      echo 下载终止,请准备好后重新下载!!!
      pause
    :END
    
  • 提交改动
    @echo off
    echo 此脚本用于windows下自动提交项目改动,
    echo 1.脚本运行前请确保电脑安装有git,git下载地址:https://git-scm.com/downloads
    echo 2.请确保脚本处于项目根目录
    
    echo.
    set /p var="是否开始提交?(yes/no)"
    if %VAR%==no GOTO DIE
    
    echo.
    echo 保存本地改动开始 ...
    git add .
    echo 保存本地改动结束!
    
    echo.
    echo 提交本地改动开始...
    echo 请输入本次主要改动的描述,请尽量简明扼要:
    set /p message=
    git commit -m "%message%"
    echo 提交本地改动结束!
    
    echo.
    echo 拉取远端改动开始 ...
    git pull
    echo 拉取远端改动结束!
    
    echo.
    echo 合并远端改动开始 ...
    git merge
    echo 合并远端改动结束!
    
    echo.
    echo 推送本地改动到远端服务器开始...
    git push origin master
    echo 推送本地改动到远端服务器结束!
    echo.
    pause
    GOTO END
    
    :DIE
      echo 提交终止,请准备好后重新提交!!!
      echo.
      pause
    :END
    

Todo:

  • 脚本运行异常,记录log并发送指定接收邮箱。
    • 设置git mailinglist:
      git config --global hooks.mailinglist "userName@companyName.com"
      
    • 在服务器的post-receive脚本最后加如下一行调用post-receive-email脚本:
      . /usr/share/git-core/contrib/hooks/post-receive-email
      
    • post-receive-email脚本最终调用的是 /usr/sbin/sendmail
      send_mail()
      {
          if [ -n "$envelopesender" ]; then
              /usr/sbin/sendmail -t -f "$envelopesender"
          else
              /usr/sbin/sendmail -t
          fi
      }
      

      如果服务器没有sendmail,使用如下命令安装:

      sudo apt-get install mailutils
      

      也可以使用postfix替换sendmail,然后对/usr/sbin/sendmail软链接至postfix。

    • 配置邮件服务器

  • 提交脚本之前的远端merge可能有冲突,这个时候需要人工干预。

git hook 脚本的当前执行路径

/foo/.git/hooks/下面的shell脚本是在/foo/目录下执行的,也就是如果在hook shell脚本里输出 pwd,显示的是 /foo/,而不是 /foo/.git/hooks/,这一点需要注意,特别是需要在hook脚本里调用外部其他shell脚本时。相对位置出错就会出现找不到被调用脚本的问题。