Git走HTTP请求了什么?200、退出码与仓库结果为什么要分开验证
浏览器能打开仓库地址是否意味着Git能克隆HTTP返回200是否意味着push成功甚至Git退出码为0是否就拿到了预期的提交先看本篇实测的两个反例服务端返回HTTP 200push却被钩子拒绝远端引用没有改变另一个地址只返回HTMLGit 2.45.2的clone退出码为0但目标提交根本不在克隆结果里。后一个行为限定于这次版本和响应不能推广成所有Git版本都会这样。这几个判断不在同一层。我们不用真正的远程托管平台而是在本机搭一个最小HTTP实验把请求日志、命令结果和仓库状态放在一起检查。本文使用标准Git不依赖mini-git的未公开修改。完整Python程序放在文末它创建独立临时目录不修改你的已有仓库或全局Git配置。1. 服务端是谁在处理Git协议Python只负责接收HTTP请求把请求方法、路径、查询参数和请求体交给git http-backend再把后者的响应交回客户端。对象协商、pack生成和更新引用都由真实Git完成不是我们在Python里模拟了一个Git实现。服务只监听127.0.0.1端口由系统分配。程序创建一个裸仓库并仅对这个新仓库设置http.receivepacktrue以允许实验push。git-http-backend官方说明解释了这个CGI后端及服务配置。这不是生产服务没有认证、TLS和并发保障不支持chunked请求请求体限制为1MiB。只用几个小文本文件避免把实验框架误当成托管平台。2. 拉取和推送分别找哪个服务实验固定请求协议版本0避免不同版本的协商细节混在一起。操作阶段请求作用拉取前发现服务GET /demo.git/info/refs?servicegit-upload-pack获知引用及能力拉取对象POST /demo.git/git-upload-pack协商并取得对象推送前发现服务GET /demo.git/info/refs?servicegit-receive-pack获知接收服务信息推送更新POST /demo.git/git-receive-pack提交对象及引用更新请求upload-pack这个名字容易误导它是服务端向拉取方提供对象的服务不是“客户端上传”。这些路径对应Git HTTP协议文档中的Smart HTTP发现和RPC过程。图把响应方向也画出来第一次返回的是引用和能力后一次才传递对象数据。H只是目标提交的代号图不是程序逐字节解码得到的pack记录。它画的是一次拉取主干不是说整段实验只有两个请求。后面还有push、fetch以及错误响应实验。程序记录方法、路径、状态码和Content-Type它没有逐字节解码pack或所有协议报文。3. 正常路径不仅执行命令还检查结果实验依次做三件事HTTP clone检查客户端HEAD等于初始提交。HTTP push客户端增加文件并提交检查裸仓库master确实指向新提交。HTTP fetch另一个本地writer先向裸仓库推送新提交再由HTTP客户端fetch。第三步中的writer通过本地路径push只是制造上游变化不能冒充HTTP测试。真正通过HTTP执行的是client的fetch。fetch后分别检查origin/master已更新、目标commit对象存在、客户端HEAD仍是fetch前的提交、工作区没有出现上游新增的文件。只写“fetch执行成功”会遗漏这些区别下载对象和跟踪远程引用不等于合并到当前分支。4. 第一种假成功HTTP 200但服务端拒绝引用更新先让客户端快进到上游再产生一个新提交。这样push本来是快进的不会提前被客户端的非快进检查挡住。随后只在实验裸仓库安装一个pre-receive钩子#!/bin/sh echo blog-hook-reject 2 exit 1再次push日志里确实有POST /demo.git/git-receive-packHTTP状态是200但Git命令失败诊断含pre-receive hook declined服务端master保持原值。这并不矛盾HTTP请求被处理了Git协议里的引用更新却被拒绝。接收结果还需要看协议反馈及仓库状态而不是把HTTP状态码当作业务结果。pack协议说明介绍了这类协议交互。5. 第二种假成功普通HTMLclone竟然返回0我们另开一个路径/html无论请求什么都返回状态200、text/html和一段固定HTML。它没有任何Git仓库数据。本机Git for Windows 2.45.2的实际结果有点反直觉clone返回0但提示You appear to have cloned an empty repository.。再用git cat-file -e 初始提交^{commit}检查目标提交不存在。因此这次不是“命令失败”而是“命令成功退出却没有获得我们要的仓库”。HTTP协议文档存在Smart与Dumb两种发现路径这个样本表现出了兼容探测行为但本文没有解码所有探测报文也不把它推广成“任何HTML都会克隆出空仓库”。不同Git版本或响应内容可能有不同结果。这里最有价值的不是记一个报错而是检查方式如果任务是取得某个已知提交那就核对提交是否存在而不是只核对进程退出码。6. 成功是哪一层的成功HTTP状态回答“传输请求怎么样”Git退出码回答“命令怎么结束”引用与对象检查才回答“仓库是否变成我需要的样子”。所以push被钩子拒绝时200仍然可以成立拿不到已知提交时退出0仍然不足以完成我们的任务。这不是多加几条断言显得更严谨而是先把任务的目标说清楚再找到能够直接回答它的证据。附录一验证程序怎么设计把程序拆成三个职责比较容易检查真实Git负责协议HTTP适配层负责转交断言负责验证可观察结果。不要让被验证的实现自己宣布“我是正确的”。程序隔离继承的Git配置和代理变量避免用户已有代理、签名设置或凭据助手干扰本地实验。所有配置只作用于子进程或新建仓库。Git与后端子进程设置超时正常及断言失败路径都在finally关闭服务并输出JSON日志这不代表服务已经具备抵御任意阻塞请求的能力。我们还做了一个反向检查删掉真正的fetch调用保留后面的断言。如果验证有效就应在“fetch更新跟踪引用”这一项失败而不能继续打印通过。这样至少证明这个关键断言不是摆设。完整程序没有依赖手抄提交ID或固定端口每次新建仓库并从实际结果取得ID。正常运行重复验证故障变体单独运行。重复运行用于排查环境偶然性不算新增测试场景。附录二如何复现准备Python 3和带http-backend的Git确保终端能执行git --version。把文末程序作为SmartHttpLab.py在你想存放实验目录的位置运行例如D盘实验目录python ./SmartHttpLab.py成功输出SMART_HTTP_CHECKS_PASSED以及RESULTS.../results.json。临时目录保留仓库和日志方便自己查看提交ID、随机目录和端口每次不同。若你的版本在HTML实验出现不同结果不要强行忽略断言。先看JSON里的请求记录和终端诊断那正是需要重新核对的版本边界。本文没有验证协议v2、TLS、真实平台认证、代理、浅克隆、大文件或故障恢复。附录三完整实验程序import json import os from pathlib import Path import shutil import subprocess import tempfile import threading from email.parser import BytesParser from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlsplit def main(): git shutil.which(git) if not git: raise RuntimeError(Git with http-backend is required) root Path(tempfile.mkdtemp(prefixgit-smart-http-, diros.getcwd())) repos root / repos repos.mkdir() env {k: v for k, v in os.environ.items() if not k.upper().startswith(GIT_) and k.lower() not in {http_proxy, https_proxy, all_proxy, no_proxy}} env.update(GIT_CONFIG_NOSYSTEM1, GIT_CONFIG_GLOBALos.devnull, GIT_TERMINAL_PROMPT0, NO_PROXY127.0.0.1,localhost) records [] checks [] def check(ok, name): if not ok: raise AssertionError(name) checks.append(name) def run(cwd, *args, expected0): cmd [git, -c, protocol.version0, -c, http.proxy, -c, credential.helper, -c, commit.gpgsignfalse, -c, user.nameBlog lab, -c, user.emailblogexample.invalid] result subprocess.run(cmd list(args), cwdcwd, envenv, capture_outputTrue, textTrue, timeout20) if expected 0: check(result.returncode 0, git .join(args) : result.stderr) else: check(result.returncode ! 0, expected Git rejection) return result def tip(repo, refHEAD): return run(repo, rev-parse, ref).stdout.strip() def commit(repo, name, text): (repo / name).write_text(text, encodingutf-8) run(repo, add, name) run(repo, commit, -m, name) return tip(repo) class Handler(BaseHTTPRequestHandler): def log_message(self, *_): pass def respond(self, status, content_type, body): self.send_response(status) self.send_header(Content-Type, content_type) self.send_header(Content-Length, str(len(body))) self.end_headers() self.wfile.write(body) records.append({method: self.command, path: self.path, status: status, type: content_type}) def handle_git(self): url urlsplit(self.path) if url.path.startswith(/html/): self.respond(200, text/html, bhtmlnot a Git repository/html) return allowed {/demo.git/info/refs, /demo.git/git-upload-pack, /demo.git/git-receive-pack} if url.path not in allowed: self.respond(404, text/plain, bnot found) return if self.headers.get(Transfer-Encoding): self.respond(501, text/plain, bchunked requests not supported by this lab) return length int(self.headers.get(Content-Length, 0)) if length 0 or length 1024 * 1024: self.respond(413, text/plain, blab request too large) return body self.rfile.read(length) cgi dict(env, GIT_PROJECT_ROOTstr(repos), GIT_HTTP_EXPORT_ALL1, PATH_INFOurl.path, QUERY_STRINGurl.query, REQUEST_METHODself.command, GATEWAY_INTERFACECGI/1.1, CONTENT_TYPEself.headers.get(Content-Type, ), CONTENT_LENGTHstr(length)) result subprocess.run([git, http-backend], cwdrepos, envcgi, inputbody, capture_outputTrue, timeout20) output result.stdout marker b\r\n\r\n if b\r\n\r\n in output else b\n\n if result.returncode or marker not in output: self.respond(502, text/plain, bbackend failed) return headers, payload output.split(marker, 1) parsed BytesParser().parsebytes(headers b\n\n) status int(parsed.get(Status, 200).split()[0]) self.respond(status, parsed.get(Content-Type, application/octet-stream), payload) do_GET handle_git do_POST handle_git run(root, init, -b, master, seed) seed root / seed first commit(seed, base.txt, base\n) run(root, clone, --bare, str(seed), str(repos / demo.git)) bare repos / demo.git run(bare, config, http.receivepack, true) server HTTPServer((127.0.0.1, 0), Handler) server.timeout 20 thread threading.Thread(targetserver.serve_forever, daemonTrue) thread.start() url fhttp://127.0.0.1:{server.server_port}/demo.git report {root: str(root), gitVersion: run(root, --version).stdout.strip(), protocol: version 0 requested, requests: records, checks: checks} try: run(root, clone, url, client) client root / client check(tip(client) first, clone HEAD equals server) pushed commit(client, client.txt, HTTP push\n) run(client, push, origin, master) check(tip(bare, master) pushed, push advances server ref) run(root, clone, str(bare), writer) writer root / writer upstream commit(writer, upstream.txt, new upstream\n) run(writer, push, origin, master) run(client, fetch, origin) check(tip(client, origin/master) upstream, fetch updates tracking ref) check(tip(client) pushed, fetch preserves HEAD) check(not (client / upstream.txt).exists(), fetch preserves worktree) run(client, cat-file, -e, upstream ^{commit}) paths {(r[method], r[path]) for r in records} for service in [upload-pack, receive-pack]: check((GET, /demo.git/info/refs?servicegit- service) in paths, discovery service) check((POST, /demo.git/git- service) in paths, RPC service) run(client, merge, --ff-only, origin/master) commit(client, rejected.txt, must not reach server ref\n) hook bare / hooks / pre-receive hook.write_text(#!/bin/sh\necho blog-hook-reject 2\nexit 1\n, encodingutf-8) hook.chmod(0o755) start len(records) rejected run(client, push, origin, master, expected1) check(pre-receive hook declined in rejected.stderr, server hook rejection diagnostic) check(tip(bare, master) upstream, rejected push preserves server ref) check(any(r[method] POST and r[path] /demo.git/git-receive-pack and r[status] 200 for r in records[start:]), HTTP 200 with rejected ref) html run(root, clone, url.replace(/demo.git, /html), bad-client) check(any(r[status] 200 and r[type] text/html for r in records), HTML 200 observed) run(root / bad-client, cat-file, -e, first ^{commit}, expected1) check(empty repository in html.stderr, HTML fallback produced empty-repository warning) report.update(passedTrue, rejectionDiagnosticrejected.stderr, htmlDiagnostichtml.stderr) print(SMART_HTTP_CHECKS_PASSED) finally: server.shutdown() server.server_close() thread.join(timeout5) report[serverStopped] not thread.is_alive() (root / results.json).write_text(json.dumps(report, indent2), encodingutf-8) print(RESULTS str(root / results.json)) if __name__ __main__: main()