开发技巧

GitHub Copilot:Copilot 会读取两类仓库自定义指令

Copilot 会读取两类仓库自定义指令:仓库级的 .github/copilot-instructions.md,以及路径级的 .github/instructions//NAME.instructions.md(通过 frontmatter 里的 applyTo 匹配文件)。 这里最容易踩的坑是 glob 的语义。按官方文档, 只匹配当前目录,src/.

BINBHO · 图文教程

Copilot 会读取两类仓库自定义指令:仓库级的 .github/copilot-instructions.md,以及路径级的 .github/instructions/**/NAME.instructions.md(通过 frontmatter 里的 applyTo 匹配文件)。 这里最容易踩的坑是 glob 的语义。按官方文档,* 只匹配当前目录,src/*.py 不会匹配 src/foo/bar.py。这和很多人熟悉的 Python fnmatch 不一样。 这篇写一个 instructions_lint.py,按文档的 glob 语义检查每个 applyTo 实际匹配了哪些文件,并检查文件名、frontmatter 和 excludeAgent 的取值。

先看清成品(box 上真实运行)

  • 文档里列出的 15 个 glob 示例,按文档描述逐条测试,全部通过。
  • 示例仓库查出 3 个 ERROR:文件名不是 .instructions.md、缺少 frontmatter、excludeAgent: "coding-agent" 不是合法值。
  • 还有 1 个 WARN:applyTo: "*.py" 只匹配了根目录下的两个脚本,src/ 和 tests/ 里的 3 个 .py 文件都没有匹配到。
  • pytest 16 项通过。

合规提醒:GitHub Copilot 需要有效的订阅或组织授权,不要共享账号,也不要用代理绕过地区或组织的限制。Copilot 会把代码上下文发送给 GitHub 和它的模型服务商处理,公司仓库先确认组织策略(比如是否允许使用 cloud agent,是否开启公开代码匹配过滤)。仓库里的说明文件、workflow、agent 配置都不要写密钥,MCP 用到的密钥放在 Agents secrets 里。本文不列价格,以官网为准。

运行说明:box 上没有登录 GitHub,也没有 Copilot 订阅,所以 Copilot 实际是否读取这些文件、cloud agent 是否真的执行了 setup 步骤,都没有验证。本文的规则全部来自 docs.github.com 的原文(文中会标出处),检查脚本、actionlint 和测试都在 box 上真实运行过。

做完之后你会得到什么?

成果 说明
.github/copilot-instructions.md 仓库级说明:技术栈、安装、测试、检查命令
.github/instructions/ 两个正确的示例、三个故意写错的反例
instructions_lint.py 检查脚本,有 ERROR 时退出码为 1
test_instructions_lint.py 16 项测试(15 个文档 glob 示例加上示例仓库)

需要准备哪些环境?

Python 3.9 以上,pip install pyyaml pytest。

官方规则(来自 add-repository-instructions 文档)

规则 内容
仓库级 .github/copilot-instructions.md
路径级 .github/instructions 目录及其子目录下的 NAME.instructions.md,文件名必须以 .instructions.md 结尾
applyTo 写在 frontmatter 里的 glob,多个模式用逗号分隔,比如 "**/*.ts,**/*.tsx"
同时匹配 路径级和仓库级同时生效时,两者都会用上
excludeAgent 可选,取值 "code-review" 或 "cloud-agent";不写的话,两者都会使用这份说明
AGENTS.md 可以放在仓库任何位置,离当前文件最近的那个优先
优先级 个人说明 > 仓库说明 > 组织说明

文件结构

58-github-copilot-A-src/
├── .github/
│   ├── copilot-instructions.md
│   └── instructions/
│       ├── backend/python.instructions.md
│       ├── frontend.instructions.md
│       ├── review.instructions.md          # 反例:excludeAgent 写错,*.py 只匹配根目录
│       ├── docs.md                         # 反例:文件名不对
│       └── no-frontmatter.instructions.md  # 反例:没有 frontmatter
├── src/, tests/, web/                      # 空文件,供 applyTo 匹配
├── instructions_lint.py
└── test_instructions_lint.py

第 1 步:说明文件

# 项目说明(给 Copilot)

这是一个 Python 3.12 + FastAPI 的订单服务,前端是 TypeScript。

- 安装依赖:`uv sync`
- 运行测试:`uv run pytest -q`
- 代码检查:`uv run ruff check .`
- 提交前必须让测试和 ruff 都通过。
<!-- .github/instructions/backend/python.instructions.md -->
---
applyTo: "src/**/*.py,tests/**/*.py"
---
- 函数都加类型注解
- 错误统一抛 `AppError(code, message)`
<!-- .github/instructions/frontend.instructions.md -->
---
applyTo: "web/*.ts,web/*.tsx"
excludeAgent: "code-review"
---
- 组件用函数式写法
<!-- .github/instructions/review.instructions.md(反例) -->
---
applyTo: "*.py"
excludeAgent: "coding-agent"
---
- 审查时重点看 SQL 注入

第 2 步:检查脚本

"""按 GitHub 官方文档检查 Copilot 仓库自定义指令。退出码:有 ERROR 为 1。
依据(docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions):
- 仓库级:.github/copilot-instructions.md
- 路径级:.github/instructions/ 下(含子目录)的 NAME.instructions.md,文件名必须以 .instructions.md 结尾
- frontmatter 里用 applyTo 指定 glob,多个模式用逗号分隔
- glob 语义:* 只匹配当前目录;** 递归;src/*.py 不匹配 src/foo/bar.py
- excludeAgent 可选,取值 "code-review" 或 "cloud-agent"
"""
import pathlib, re, sys
import yaml

ROOT = pathlib.Path(".")
EXCLUDE = {"code-review", "cloud-agent"}

def glob_to_regex(pat):
    i, out = 0, ""
    while i < len(pat):
        if pat.startswith("**/", i):
            out += r"(?:.*/)?"; i += 3
        elif pat.startswith("**", i):
            out += r".*"; i += 2
        elif pat[i] == "*":
            out += r"[^/]*"; i += 1
        elif pat[i] == "?":
            out += r"[^/]"; i += 1
        else:
            out += re.escape(pat[i]); i += 1
    return re.compile(out + r"\Z")

def matches(pat, path):
    return bool(glob_to_regex(pat).match(path))

def lint(root=ROOT):
    out = []
    files = [p.relative_to(root).as_posix() for p in root.rglob("*")
             if p.is_file() and ".git" not in p.parts and not p.relative_to(root).as_posix().startswith(".github/")]
    if not (root / ".github/copilot-instructions.md").exists():
        out.append(("INFO", ".github/copilot-instructions.md", "没有仓库级说明"))
    idir = root / ".github/instructions"
    for p in sorted(idir.rglob("*.md")) if idir.exists() else []:
        rel = p.relative_to(root).as_posix()
        if not p.name.endswith(".instructions.md"):
            out.append(("ERROR", rel, "文件名必须以 .instructions.md 结尾,否则不会被使用")); continue
        m = re.match(r"^---\n(.*?)\n---\n", p.read_text(encoding="utf-8"), re.S)
        if not m:
            out.append(("ERROR", rel, "缺少包含 applyTo 的 frontmatter")); continue
        try:
            fm = yaml.safe_load(m.group(1)) or {}
        except yaml.YAMLError as e:
            out.append(("ERROR", rel, f"frontmatter YAML 无效:{str(e).splitlines()[0]}")); continue
        apply = fm.get("applyTo")
        if not apply:
            out.append(("ERROR", rel, "缺少 applyTo")); continue
        ex = fm.get("excludeAgent")
        if ex is not None and ex not in EXCLUDE:
            out.append(("ERROR", rel, f"excludeAgent={ex!r} 无效,只能是 code-review 或 cloud-agent"))
        for k in set(fm) - {"applyTo", "excludeAgent"}:
            out.append(("WARN", rel, f"未知字段 {k}"))
        for pat in [x.strip() for x in str(apply).split(",") if x.strip()]:
            hits = [f for f in files if matches(pat, f)]
            if not hits:
                out.append(("WARN", rel, f"applyTo 模式 {pat} 没有匹配到任何文件"))
            else:
                out.append(("OK", rel, f"{pat} → {len(hits)} 个文件,如 {hits[0]}"))
            if "/" not in pat:          # 没有 / 的模式只匹配根目录,子目录里的同类文件全部漏掉
                deeper = [f for f in files if "/" in f and matches(pat, f.rsplit("/", 1)[1])]
                if deeper:
                    out.append(("WARN", rel, f"{pat} 只匹配根目录;子目录里还有 {len(deeper)} 个同类文件(如 {deeper[0]}),想递归请写 **/{pat}"))
    return out

if __name__ == "__main__":
    res = lint(pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT)
    for lvl, f, msg in res:
        print(f"[{lvl}] {f}: {msg}")
    sys.exit(1 if any(l == "ERROR" for l, _, _ in res) else 0)

第 3 步:测试

import pytest
from instructions_lint import matches, lint

# 官方文档列出的 glob 示例
@pytest.mark.parametrize("pat,path,ok", [
    ("*", "README.md", True), ("*", "src/a.py", False),
    ("**", "src/a/b.py", True), ("**/*", "src/a/b.py", True),
    ("*.py", "a.py", True), ("*.py", "src/a.py", False),
    ("**/*.py", "a.py", True), ("**/*.py", "src/a/b.py", True),
    ("src/*.py", "src/foo.py", True), ("src/*.py", "src/foo/bar.py", False),
    ("src/**/*.py", "src/foo.py", True), ("src/**/*.py", "src/foo/bar/baz.py", True),
    ("**/subdir/**/*.py", "subdir/foo.py", True), ("**/subdir/**/*.py", "deep/parent/subdir/nested/qux.py", True),
    ("**/subdir/**/*.py", "foo.py", False),
])
def test_doc_glob_examples(pat, path, ok):
    assert matches(pat, path) is ok

def test_sample_repo():
    res = {(l, f, m) for l, f, m in lint()}
    msgs = " | ".join(f"{l} {f} {m}" for l, f, m in res)
    assert "ERROR .github/instructions/docs.md 文件名必须以 .instructions.md 结尾" in msgs
    assert "ERROR .github/instructions/no-frontmatter.instructions.md 缺少包含 applyTo" in msgs
    assert "excludeAgent='coding-agent' 无效" in msgs
    assert "*.py 只匹配根目录;子目录里还有 3 个同类文件" in msgs
    assert "OK .github/instructions/backend/python.instructions.md src/**/*.py → 2 个文件" in msgs

第 4 步:运行

$ python instructions_lint.py
[OK] .github/instructions/backend/python.instructions.md: src/**/*.py → 2 个文件,如 src/api/orders.py
[OK] .github/instructions/backend/python.instructions.md: tests/**/*.py → 1 个文件,如 tests/test_orders.py
[ERROR] .github/instructions/docs.md: 文件名必须以 .instructions.md 结尾,否则不会被使用
[OK] .github/instructions/frontend.instructions.md: web/*.ts → 1 个文件,如 web/app.ts
[OK] .github/instructions/frontend.instructions.md: web/*.tsx → 1 个文件,如 web/App.tsx
[ERROR] .github/instructions/no-frontmatter.instructions.md: 缺少包含 applyTo 的 frontmatter
[ERROR] .github/instructions/review.instructions.md: excludeAgent='coding-agent' 无效,只能是 code-review 或 cloud-agent
[OK] .github/instructions/review.instructions.md: *.py → 2 个文件,如 instructions_lint.py
[WARN] .github/instructions/review.instructions.md: *.py 只匹配根目录;子目录里还有 3 个同类文件(如 tests/test_orders.py),想递归请写 **/*.py
$ python -m pytest -q
16 passed in 0.02s

调试示例

1. *.py 匹配到的不是我以为的文件。 我原本预期 review.instructions.md 里的 applyTo: "*.py" 什么都匹配不到,测试也是这样写的,但测试失败了。实际输出是 *.py → 2 个文件,如 instructions_lint.py:它匹配到了根目录下的两个检查脚本,而真正需要审查的 src/、tests/ 里的文件一个都没匹配到。这正好符合文档的语义(*.py 只匹配当前目录)。所以“匹配到 0 个文件”并不是唯一的错误信号,我新增了一条 WARN:模式里没有 /,并且子目录里还有同类文件时提示改成 **/*.py。

2. 在 pytest 里,脚本检查的根本不是仓库。 改完之后测试还是失败,报错显示只检查了 INFO .github/copilot-instructions.md 没有仓库级说明。原因是 ROOT = pathlib.Path(sys.argv[1]) 写在了模块顶层,而在 pytest 里 sys.argv[1] 是测试文件的路径。改成只在 __main__ 里读取命令行参数后,16 项测试全部通过。

常见问题

现象 原因 处理
路径级说明从来没有生效 文件名没有以 .instructions.md 结尾,或者不在 .github/instructions 下 改名或移动文件
src/*.py 漏掉了子目录 * 不跨目录 改用 src/**/*.py
代码审查也用了只想给 cloud agent 的说明 没有写 excludeAgent 加上 excludeAgent: "code-review"
不确定说明有没有被用上 — 在 Copilot Chat 的回答里展开 References,看是否列出了说明文件
说明之间互相矛盾 个人、仓库、组织三层都写了 按优先级整理,避免冲突

模板规则

  • 仓库级说明写构建、测试、检查命令,并且每条命令都要实际验证过。
  • 路径级说明用 ** 递归匹配,用检查脚本确认每个模式至少匹配到一个文件。
  • 检查脚本放进 CI。

项目资源

用途 地址
Copilot 文档首页 https://docs.github.com/en/copilot
添加仓库自定义指令 https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions

说明文件写对了,Copilot 在每个目录下都会按团队的约定写代码和审查代码。