内网一台常年开机的 Windows 机器,想拿来当团队的 Git 中心,很多人的第一反应是共享一个文件夹,然后本地用映射网络驱动器或者file://路径直接指向去。这个路子我试过,短则两周、长则三个月,一定会出问题——不是锁文件残留,就是两个人同时推送把仓库搞坏。真正稳的做法,是在这台 Windows 上把 OpenSSH 服务开起来,让 Git 走 SSH 协议,客户端用git@主机:仓库路径的方式访问。这篇就把这套方案从环境准备、账户密钥、仓库布局、排错链路到后续运维完整拆一遍,重点讲清楚每一步为什么这么做、Windows 环境下跟 Linux 有哪些不一样的地方。适合手上有一台闲置 Windows 机器、团队规模不大、又不想把代码放到外部托管平台上的开发者参考;也适合已经搭了一半、卡在Permission denied或者command not found上的人直接对照排查。
1. 先想清楚:为什么共享目录当远端这条路走不通
1.1 网络共享盘上跑 Git 的三个致命伤
Git 的并发安全依赖文件锁。本地磁盘上,index.lock、packed-refs.lock这些锁文件由操作系统保证原子性创建,O_CREAT | O_EXCL一行搞定。但一旦落到 SMB 网络共享上,情况就变了:不同客户端对同一个共享路径的锁语义并不可靠,尤其是跨机器、跨会话的情况下,两台机器几乎同时执行写操作,锁的创建和检查之间存在时间窗口,谁都能拿到"锁",结果就是仓库元数据被写花。我见过最典型的一次,是两个人前后脚推送,第二个人的packed-refs写了一半,之后所有人git clone都报fatal: bad object,只能从备份恢复。
第二个问题是性能。走 SMB 的 Git 操作会把大量小文件读写放大成网络往返,git status从 0.1 秒变成 3 秒是常态,git log -p更是慢到让人放弃。第三个问题是权限模型错位:NTFS 的 ACL 和 Git 自己记录的core.filemode、可执行位完全是两套东西,在共享目录里chmod +x到底生不生效,取决于服务端共享配置,排查起来非常难受。
1.2 SSH 承载 Git 其实只做了一件很薄的事
很多人把"搭 Git 服务器"想得很重,以为要装一套服务端软件。实际上 Git 原生支持的传输协议里,SSH 这一路非常轻:客户端发起git push,本质是通过 SSH 在远端执行一条命令git-receive-pack '仓库路径',然后把打包好的对象数据从标准输入灌进去;git fetch则是执行git-upload-pack '仓库路径',从标准输出读回来。SSH 在这里只提供两样东西——一条加密的双向字节流,和一次身份认证。
理解这一点,整套方案的关键要素就非常清楚了:服务端要有一个能通过 SSH 登录的账户,登录后的 shell 环境里能找到 git 相关命令,并且目标路径下存在一个合法的裸仓库。三件事缺一不可,后面所有报错基本都能归到这三件事上。至于 Git 本身,它完全不知道也不关心对面是 Linux 还是 Windows,它只关心那条管道通不通、命令跑不跑得起来。
1.3 这套方案的适用边界
自建 SSH 服务不是万能药,先把边界划清楚能省很多事。
| 场景特征 | 自建 SSH 是否合适 | 说明 |
|---|---|---|
| 3~10 人小团队,内网为主 | 合适 | 维护成本低,一台常开机器就够 |
| 需要 Web 界面看代码、提合并请求 | 不合适 | SSH 只提供 Git 协议,没有界面 |
| 需要细粒度分支权限、审计日志 | 勉强 | 得靠服务端钩子自己写 |
| 有合规要求、代码不能出内网 | 合适 | 数据完全在自己手里 |
| 团队分散、机器不稳定 | 不合适 | 单点故障,机器一挂全员停摆 |
| 个人多设备同步 | 合适 | 一把密钥,一个裸仓库,够用 |
把这几个问题想明白再动手,比装完了才发现需求不匹配要划算得多。
2. OpenSSH 服务端与 Git 客户端的安装顺序有讲究
2.1 用系统可选功能装服务端,别去第三方站点下压缩包
Windows 10 1809 之后、Windows Server 2019 之后,系统自带 OpenSSH 服务端的可选功能,这是最干净的安装方式——后续系统更新会一起维护,不用担心版本对不上。图形界面路径是"设置 → 应用 → 可选功能 → 添加功能 → OpenSSH 服务器",但更推荐直接用管理员权限的 PowerShell,一条命令解决:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 Start-Service sshd Set-Service -Name sshd -StartupType Automatic装完之后先做三件验证,缺一不可。第一,ssh -V看版本,能出版本号说明客户端也在(服务端和客户端是两个独立的可选功能,通常一起装)。第二,Get-Service sshd确认状态是Running、启动类型是Automatic,很多人装完忘了设自启,机器重启一次服务就没了。第三,netstat -ano | findstr :22确认端口在监听;如果 22 被别的程序占了(某些远程管理软件会抢),需要改sshd_config里的Port,改完记得同步放行防火墙。
配置文件的位置是个常见误区:不是C:\Program Files\OpenSSH\,而是C:\ProgramData\SSH\sshd_config。ProgramData是隐藏目录,直接输路径进。这个文件每次改完都要Restart-Service sshd才生效。
2.2 Git 安装时那个 PATH 选项,直接决定后续一半的报错
Git for Windows 安装向导里有个"Adjusting your PATH environment"页面,三个选项分别是:只用 Git Bash、从命令行和第三方软件使用 Git、只用命令行。必须选中间那个("Git from the command line and also from 3rd-party software")。
理由很直接:sshd是以 Windows 服务的身份运行的,它启动时继承的是系统级环境变量,而不是你当前登录用户的 PATH。如果安装时选了第一项,git.exe只存在于 Git 自带的那套 bash 环境里,服务端的 cmd 或 PowerShell 根本找不到它,客户端一连上来就报git-receive-pack: command not found。选中间项会把C:\Program Files\Git\cmd写进系统 PATH,这正是我们要的。
如果已经装错了也不用重装,手动补就行,管理员 PowerShell 执行:
$old = [Environment]::GetEnvironmentVariable("Path", "Machine") [Environment]::SetEnvironmentVariable("Path", "$old;C:\Program Files\Git\cmd", "Machine") Restart-Service sshd注意末尾那句Restart-Service sshd,这是最容易漏的一步——环境变量改了,但已经跑着的服务进程不会重新读,必须重启服务才生效。这个细节我在后面排错章节还会再展开,因为它是最高频的坑。
2.3 默认 shell 定不下来,后面全是麻烦
Windows OpenSSH 默认给登录会话分配cmd.exe作为 shell,这跟 Linux 上默认 bash 的习惯差很多。三种选择各有代价:
| 默认 shell | 配置方式 | 优点 | 风险 |
|---|---|---|---|
| cmd.exe(默认) | 不用配 | 零配置 | 引号处理怪,脚本能力弱 |
| PowerShell | 改注册表DefaultShell | 能力强,Windows 原生 | 启动慢,某些 git 输出解析异常 |
| Git Bash | 改注册表DefaultShell+ 命令行选项 | 跟 Linux 行为最接近,钩子脚本好写 | 路径映射需要注意 |
改注册表的方式如下,管理员权限执行,然后重启 sshd:
New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell ` -Value "C:\Program Files\Git\bin\bash.exe" -PropertyType String -Force New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShellCommandOption ` -Value "-c" -PropertyType String -Force Restart-Service sshd我的建议是:如果团队里有人要写服务端钩子脚本,直接上 Git Bash,因为钩子脚本基本都是#!/bin/sh写的,用 bash 当默认 shell 省掉一层转译;如果只是纯粹托管代码、不打算写钩子,保持 cmd.exe 也完全够用,反而少一层不确定性。
3. 账户与密钥:把认证这件事做扎实
3.1 专用服务账户和复用登录账户,选哪个
最简单的做法是复用现有 Windows 账户,ssh 用户名@主机直接登。但这种做法有两个隐患:一是这个账户往往同时是日常登录账户,权限过大,一旦私钥泄露影响面不受控;二是账户如果属于Administrators组,Windows OpenSSH 会走一套特殊的密钥文件路径,这是新手最容易栽的坑。
更规范的做法是建一个专用账户,比如叫git,只用于托管仓库,不给他任何额外权限。创建命令:
$pwd = ConvertTo-SecureString "一个足够复杂的密码" -AsPlainText -Force New-LocalUser -Name "git" -Password $pwd -PasswordNeverExpires -AccountNeverExpires-PasswordNeverExpires是必须的,否则密码到期后密钥登录也可能被策略挡掉。建完之后别把它加进管理员组,就当成普通用户用。
3.2 authorized_keys 在 Windows 上的权限暗礁
这是 Windows 上搭 Git over SSH 最高频的失败点,值得单独拎出来讲。
OpenSSH 有个StrictModes检查,默认开启,会校验authorized_keys文件及其上级目录的权限:只能由文件所有者和系统管理员写入。在 Linux 上大家习惯chmod 600,但 Windows 没有这套数字权限,用的是 ACL。而 Windows 新建文件默认会继承父目录的 ACL,C:\Users\git\.ssh\这个目录如果继承了Users组的读权限还无伤大雅,但如果被继承了什么"可写"的权限,sshd 就直接拒绝认证,而且报错信息藏在日志里,客户端只看到Permission denied (publickey),非常误导。
标准修复流程,管理员 PowerShell 执行:
$keyfile = "C:\Users\git\.ssh\authorized_keys" icacls $keyfile /inheritance:r icacls $keyfile /grant "git:F" icacls $keyfile /grant "SYSTEM:F" icacls $keyfile /grant "Administrators:F"翻译一下:先切断继承(/inheritance:r),然后只显式授予git账户、SYSTEM、Administrators完全控制。三步少一步都可能出问题。目录C:\Users\git\.ssh同样要做一遍。
还有一个管理员账户的特殊情况:如果登录账户属于Administrators组,Windows OpenSSH 会优先读取C:\ProgramData\SSH\administrators_authorized_keys而不是用户目录下的那个文件。这个行为写在默认sshd_config的Match Group administrators段里。所以用管理员账户登录时把公钥贴到用户目录是白贴的,必须贴到administrators_authorized_keys,而且这个文件同样要做上面的权限收紧。遇到那种"配置文件检查了八百遍、公钥明明一模一样、就是登不上"的情况,十有八九是撞在这上面。
3.3 客户端侧:生成密钥与别名配置
服务端准备就绪,客户端这边推荐用 ed25519,短、快、安全性足够:
ssh-keygen -t ed25519 -C "dev-laptop" -f ~/.ssh/id_ed25519_gitsrv-C后面的注释写清楚是哪台机器,将来服务端要清理某把密钥时一眼能认出来,这是运维习惯问题,别嫌麻烦。生成后把.pub文件内容追加到服务端的authorized_keys里,多个公钥就多行追加,互不影响。
为了少敲点字,在客户端~/.ssh/config里加个别名:
Host gitsrv HostName 192.168.1.50 Port 22 User git IdentityFile ~/.ssh/id_ed25519_gitsrv IdentitiesOnly yes加IdentitiesOnly yes是为了防止客户端把所有的密钥挨个试一遍,那是Permission denied之外的另一种恶心情况——服务器上失败次数太多触发限制。配好之后ssh gitsrv一条命令验证,能登进去说明认证这一层彻底通了,再去碰 Git 的事。
4. 仓库放哪、怎么建、URL 路径怎么写
4.1 为什么服务端仓库必须是裸仓库
本地开发用的仓库有工作区(一堆源文件)加.git目录两部分。服务端不需要工作区,因为没人会在服务器上编辑文件,只需要.git里那些对象和引用。所以服务端仓库用git init --bare创建,目录里直接就是objects/、refs/、HEAD、config,没有工作区。
如果不小心用普通仓库当远端,客户端推送时会收到remote: error: refusing to update checked out branch,因为那个分支正在被"检出"状态持有。这是 Git 的保护机制,不是配置错。真碰上要么重建裸仓库,要么手动设receive.denyCurrentBranch=ignore——但我劝你别这么干,后面大概率会因为服务端工作区状态和仓库状态不一致产生莫名其妙的问题。
4.2 目录规划:别放在用户目录下
仓库路径的选择有三个考量:路径长度、权限继承、备份便利性。
Windows 有 260 字符的路径长度限制(虽然新版本可以通过策略放开,但没必要给自己找麻烦)。把仓库放在D:\git-repos\这种浅路径下,比放在C:\Users\git\Documents\各种嵌套\要安全得多。
权限继承方面,仓库目录不要让Users组有写权限,否则任何本机登录用户都能改仓库内容,SSH 那一层认证就形同虚设。初始化目录后立刻收紧:
New-Item -ItemType Directory -Path "D:\git-repos" -Force icacls "D:\git-repos" /inheritance:r icacls "D:\git-repos" /grant "git:(OI)(CI)F" icacls "D:\git-repos" /grant "SYSTEM:(OI)(CI)F" icacls "D:\git-repos" /grant "Administrators:(OI)(CI)F"(OI)是对象继承、(CI)是容器继承,意思是"这个目录和里面所有东西"都给git账户完全控制。
建仓库本身很简单,用git账户登录后执行:
cd /d/git-repos git init --bare demo.git如果默认 shell 是 cmd,那就是cd D:\git-repos然后git init --bare demo.git,效果一样。
4.3 URL 的三种写法与实测差异
这部分文档里讲得含糊,实际用起来坑不少,直接给对照表:
| 客户端写法 | 服务端解析结果 | 适用场景 |
|---|---|---|
git@gitsrv:demo.git | 相对于 git 账户的家目录 | 仓库放在家目录下时 |
git@gitsrv:D:/git-repos/demo.git | 盘符绝对路径 | 最常用的绝对路径写法 |
ssh://git@gitsrv:22/D:/git-repos/demo.git | 盘符绝对路径(注意前面的斜杠) | 需要显式指定端口时 |
第三种写法里那个/D:/前面的斜杠是必须的,这是 URL 路径的根,去掉就会变成相对路径。另外 Git Bash 环境下/d/git-repos/demo.git这种 MSYS 风格路径也能被正确转换,但用cmd当默认 shell 时就要写D:/...,混用是常见翻车点。
推荐统一用git@gitsrv:D:/git-repos/demo.git的 scp 风格写法,短且不容易出错。真要跨平台协作、担心解析差异,就用ssh://的完整写法,兼容性最好。
4.4 从零到第一次推送成功的完整验证
按顺序来,每一步都可以独立验证,别跳过:
# 客户端本地已有项目 cd /path/to/my-project git remote add origin git@gitsrv:D:/git-repos/my-project.git git push -u origin master嫌麻烦直接在服务端建仓库,客户端也可以:
git init --bare demo.git # 服务端先建 git clone git@gitsrv:D:/git-repos/demo.git # 客户端拉推送成功的输出里会看到remote: Resolving deltas: 100%这类服务端信息,说明服务端的 git 已经在正常工作了。如果只看到fatal: Could not read from remote repository,别急着改配置,先按下一章的层级往下查。
5. 排错链路:一次失败的连接要分四层来看
5.1 分层排查的固定动作
线上出问题最忌讳乱改配置,一套固定的分层动作能大幅缩短定位时间。我的习惯是从下往上,每一层用一个最小命令验证:
第一层,网络。Test-NetConnection 192.168.1.50 -Port 22(PowerShell)或者telnet 主机 22。不通就是防火墙、端口没监听、或者 IP 写错,跟 SSH 配置一点关系都没有,别去翻sshd_config。
第二层,认证。ssh -v gitsrv,看 verbose 输出。重点看两行:Offering public key有没有出现,Server accepts key有没有出现。前者说明客户端拿出了密钥,后者说明服务端认可了。如果停在Authentications that can continue: publickey,就是authorized_keys路径或权限问题。
第三层,shell。认证过了但命令跑不起来,就是这一层。登进去执行where git,如果找不到,说明 PATH 问题。再执行git --version确认能跑。
第四层,git。前三层都通了,才轮到仓库路径的事。手动模拟一次 Git 的调用:
ssh gitsrv "git-receive-pack 'D:/git-repos/demo.git'"这行命令如果返回一串0000开头的协议文本然后正常退出,说明服务端一切正常,问题一定在客户端的 URL 或者本地仓库状态上。如果报does not appear to be a git repository,就是路径不对或者那个目录不是裸仓库。
5.2 报错对照表
| 报错信息 | 卡在哪层 | 根因 | 处理 |
|---|---|---|---|
Connection refused | 网络 | 服务没起或端口不对 | Get-Service sshd确认,netstat看端口 |
Permission denied (publickey) | 认证 | 公钥没到位或权限超限 | 检查 authorized_keys 位置与 ACL,看 OpenSSH 日志 |
bash: git-receive-pack: command not found | shell | 系统 PATH 没有 Git | 补系统 PATH 后重启 sshd |
does not appear to be a git repository | git | 路径写错或不是裸仓库 | 用绝对路径,git rev-parse --is-bare-repository验证 |
fatal: protocol error: bad line length character | shell | 登录 shell 输出了额外内容 | 检查开机脚本、ProFile 里的 echo |
Authentication refused: bad ownership or modes | 认证 | ACL 太宽 | icacls收紧 |
最后那条bad ownership or modes只会出现在服务端日志里,客户端看不到,所以排查认证问题时一定要去看日志。Windows 上的位置是事件查看器 → 应用程序和服务日志 → OpenSSH → Operational,开启方式是在sshd_config里加SyslogFacility LOCAL0和LogLevel DEBUG1,调试完记得把DEBUG1改回INFO,不然日志会把公钥指纹、来源 IP 全记下来。
5.3 一次真实的"命令找不到"复盘
说个我自己栽过的跟头。有一台机器,服务装完、账户建好、密钥配好、ssh gitsrv顺利登进去,git --version也正常,但客户端一clone就报git-receive-pack: command not found。奇怪的是同样一条命令,手动ssh上去敲就没问题。
排查过程是这样的。第一步,怀疑 PATH 不一致,于是用ssh gitsrv "echo %PATH%"把非交互式会话的 PATH 打出来——果然,跟登录后的 PATH 长得不一样,缺了C:\Program Files\Git\cmd。到这里原因就清楚了:装 Git 的时候改的是当前用户的 PATH,而sshd服务是以SYSTEM身份运行的,读的是系统 PATH,两套环境变量互相看不见。
第二步,修复。把C:\Program Files\Git\cmd加进系统 PATH,重启 sshd,再测——还是不行。这时候才想起去看服务的启动时间:Get-Process sshd | Select-Object StartTime,发现是服务启动时间早于改 PATH 的时间,说明重启没生效,或者更准确地说,Restart-Service sshd之后服务的父进程和进程环境没有完全刷新。最后是Stop-Service sshd; Start-Service sshd分开执行才真正生效。
这个坑的教训是两点。一是Windows 上改环境变量一定要区分用户级和系统级,服务跑的进程只认系统级。二是改了环境变量,必须真正重启服务进程,Restart-Service偶尔会因为进程句柄没释放而起不到预期效果,保险起见停掉再起。这之后我养成个习惯:任何服务端配置改动,改完先Stop-Service再Start-Service,而不是图省事直接 restart。
6. 权限模型、钩子脚本与长期运维
6.1 一个账户多人用,权限模型会缺一块
前面建议建一个专用git账户,实践中往往变成整个团队共用这一个账户、每人一把公钥追加到同一个authorized_keys里。这样做认证是通的,但身份是混的:服务端完全无法区分这次推送是谁干的。
影响在哪?Git 提交里的user.name、user.email是客户端自己填的,改一改就能冒充任何人,这不是漏洞,这是分布式设计使然。但如果你希望服务端有可信的操作记录,就得靠authorized_keys的command前缀或者environment选项做标记,或者干脆一人一个 Windows 账户。
实测下来,5 人以内的小团队,共用git账户 + 客户端自觉填真实姓名,已经足够;超过这个规模或者有审计需求,建议一人一个账户,把所有账户加进同一个本地组,然后对仓库目录给这个组授权:
New-LocalGroup -Name "gitusers" Add-LocalGroupMember -Group "gitusers" -Member "devA","devB" icacls "D:\git-repos" /grant "gitusers:(OI)(CI)M"给M(修改)就够了,不给F(完全控制),避免误删仓库目录本身。
6.2 服务端钩子在 Windows 上的写法
钩子脚本是自建方案最有价值的部分,托管平台帮你做的分支保护、大小限制、提交信息校验,这里都能自己实现。裸仓库的钩子放在hooks/目录下,文件名必须是pre-receive、post-receive这种精确匹配,没有后缀。
关键点在于脚本解释器。Windows 没有系统级的/bin/sh,但 Git for Windows 自带了一个 MSYS 环境,只要脚本第一行是#!/bin/sh,Git 执行钩子时会用自带的 sh 去跑,兼容性很好。所以钩子直接用 shell 写,不用改 PowerShell,反而省事。
一个实用的pre-receive示例,干两件事:拒绝超过 50MB 的对象、禁止直接推 master:
#!/bin/sh ZERO="0000000000000000000000000000000000000000" MAXSIZE=52428800 while read oldrev newrev refname; do if [ "$refname" = "refs/heads/master" ] && [ "$oldrev" != "$ZERO" ]; then echo "reject: direct push to master is not allowed" >&2 exit 1 fi for obj in $(git rev-list --objects "$oldrev..$newrev" 2>/dev/null); do size=$(git cat-file -s "$obj" 2>/dev/null) if [ -n "$size" ] && [ "$size" -gt "$MAXSIZE" ]; then echo "reject: object $obj is $size bytes, over limit" >&2 exit 1 fi done done exit 0几个实战注意点。第一,post-receive里不能用echo输出调试信息,标准输出会被客户端当协议数据解析,会报protocol error: bad line length character,调试信息一律走>&2。第二,钩子脚本不需要chmod +x,Git for Windows 通过 sh 调用,看的是文件名不是执行位,但为了将来迁到 Linux 时不踩坑,顺手设一下没坏处。第三,pre-receive每次推送都会跑,别在里面做耗时操作(比如全量扫描所有对象),否则推送会变得极慢。
6.3 备份、日志与升级
自建方案最大的风险是单点故障,机器一坏,代码又不在别处,那就真的没了。备份策略我一般用两层:一是每天用git bundle做增量打包,这个格式本身就是仓库的完整镜像,恢复时git clone repo.bundle直接可用;二是定期把D:\git-repos整个目录同步到另一台机器或者移动硬盘上,裸仓库就是一堆文件,直接拷贝是合法的,但必须在没人推送的时候拷,否则可能拷到半截的 pack 文件。
日志方面,OpenSSH 的日志在事件查看器里,建议开一个小的轮转策略,别让它无限增长。sshd_config里的LogLevel默认INFO就够用,排查问题时临时调到DEBUG1,排查完立刻改回来。
升级这块,OpenSSH 跟着系统更新走,不用特别操心。Git for Windows 需要手动升级,升级前确认一下有没有正在跑的钩子脚本依赖特定版本行为,升级后重启一次 sshd——因为 PATH 里git.exe的版本变了,服务进程需要重新加载。
7. 什么时候该承认这套方案该收手
搭完用上一段时间,多数人会发现瓶颈不在 Git 协议本身,而在协作流程。举个例子,同事想让你 review 一下他的改动,SSH 方案下只能他自己开个分支推上来,然后告诉你分支名,你自己git fetch下来git diff。想做合并前的讨论、想做状态看板、想知道某个功能有没有人正在改,都得靠口头沟通。
这种情况我见过太多次:一开始只想托管代码,用着用着需求就往协作平台的方向长。所以判断标准其实很简单——如果团队里超过三分之一的人每周都会问"这个分支谁在做""这个改动能不能合",那说明纯 SSH 方案已经不够用了,该考虑加一层 Web 前端,或者直接迁到有界面的方案上去。但如果只是几个人同步代码、偶尔备份,那这套方案能稳稳用很多年,而且它有个天然优势:所有东西都在你自己手里,网络断了照样内网推拉,不受任何外部服务影响。
最后分享一个实操小技巧。Windows 上有个容易被忽略的问题:机器休眠之后 SSH 连接会挂,客户端报Connection reset by peer,重连就好,但如果赶上git push跑到一半,可能会留下index.lock或者不完整的包,下一次推送又被拒。我的处理习惯是服务端机器直接关掉休眠,或者在sshd_config里加ClientAliveInterval 60和ClientAliveCountMax 3,让连接有个心跳,减少半死连接堆积。这个参数写在全局配置里就行,改动很小,但省掉的排查时间不少。