Гейт видит скрипты и Dockerfile, а pre-commit — затронутые файлы
- шаги shell (shellcheck) и dockerfile (hadolint) заведены; два правила hadolint подавлены поимённо с причиной — DL3007 до задачи pin-runtime-image-base и DL3018 по существу - lefthook гоняет на затронутых файлах gofmt, golangci-lint по их каталогам, shellcheck, hadolint и gitleaks — около секунды; полный набор в pre-commit не переносится намеренно - заведён мутационный тест скрипта сверки версий: 20 сценариев спеки toolchain плюс требование сообщения называть все четыре места, независимость исхода от установленного go и запрет звать go, docker и сеть
This commit is contained in:
@@ -35,6 +35,8 @@ tasks:
|
|||||||
fi
|
fi
|
||||||
- go test ./...
|
- go test ./...
|
||||||
- golangci-lint run
|
- golangci-lint run
|
||||||
|
- task: shell
|
||||||
|
- task: dockerfile
|
||||||
- task: go-version
|
- task: go-version
|
||||||
- task: docs
|
- task: docs
|
||||||
- task: tasks
|
- task: tasks
|
||||||
@@ -42,6 +44,38 @@ tasks:
|
|||||||
# Последним: единственный шаг, которому нужна сеть, и самый долгий.
|
# Последним: единственный шаг, которому нужна сеть, и самый долгий.
|
||||||
- task: vulns
|
- task: vulns
|
||||||
|
|
||||||
|
shell:
|
||||||
|
desc: 'shellcheck на скрипты оболочки'
|
||||||
|
cmds:
|
||||||
|
# Скриптов два и оба свои: шаг сверки версий и `docker/entrypoint.sh`.
|
||||||
|
# Второй в образ копируется, но не исполняется — `ENTRYPOINT` в
|
||||||
|
# `Dockerfile` закомментирован, — и проверяется он именно поэтому: код,
|
||||||
|
# который никто не гоняет, портится незаметно. Ни один из двух не виден ни
|
||||||
|
# `go vet`, ни `golangci-lint`.
|
||||||
|
- |
|
||||||
|
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||||
|
echo "shellcheck не найден в PATH"
|
||||||
|
echo "поставь: apt install shellcheck (или https://github.com/koalaman/shellcheck)"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
shellcheck scripts/check-go-version.sh docker/entrypoint.sh
|
||||||
|
|
||||||
|
dockerfile:
|
||||||
|
desc: 'hadolint на Dockerfile'
|
||||||
|
cmds:
|
||||||
|
# DL3007 (`alpine:latest` у рантайм-слоя) подавлен: это открытая задача
|
||||||
|
# `pin-runtime-image-base`, и до её решения шаг краснел бы на известном.
|
||||||
|
# DL3018 (закрепить версии пакетов `apk`) подавлен по существу: alpine не
|
||||||
|
# держит старые версии в репозитории, и закрепление ломает сборку через
|
||||||
|
# недели — то есть лечение хуже болезни.
|
||||||
|
- |
|
||||||
|
if ! command -v hadolint >/dev/null 2>&1; then
|
||||||
|
echo "hadolint не найден в PATH"
|
||||||
|
echo "поставь: https://github.com/hadolint/hadolint/releases"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
hadolint --ignore DL3007 --ignore DL3018 Dockerfile
|
||||||
|
|
||||||
go-version:
|
go-version:
|
||||||
desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md'
|
desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md'
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
# Refer for explanation to following link:
|
# Refer for explanation to following link:
|
||||||
# https://lefthook.dev/configuration/
|
# https://lefthook.dev/configuration/
|
||||||
|
#
|
||||||
|
# Предкоммитные проверки — дешёвая часть гейта на **затронутых файлах**. Полный
|
||||||
|
# набор здесь не гоняется намеренно: он идёт минуты, а pre-commit обязан быть
|
||||||
|
# быстрым. Что ловит pre-commit и что остаётся только гейту — CLAUDE.md,
|
||||||
|
# раздел «Гейт»; перечень правил и их дома — docs/autotests.md.
|
||||||
|
|
||||||
templates:
|
templates:
|
||||||
av-hooks-dir: "/home/av/projects/private/git-hooks"
|
av-hooks-dir: "/home/av/projects/private/git-hooks"
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
jobs:
|
jobs:
|
||||||
|
# Форматирование правится на месте и добавляется в коммит: спорить тут не о
|
||||||
|
# чем, а гейт на неотформатированном файле краснеет.
|
||||||
|
- name: "gofmt"
|
||||||
|
glob: "*.go"
|
||||||
|
run: "gofmt -w {staged_files}"
|
||||||
|
stage_fixed: true
|
||||||
|
|
||||||
|
# Линтеры гоняются по пакетам затронутых файлов, а не по всему дереву:
|
||||||
|
# golangci-lint принимает файлы только из одного каталога, поэтому на вход
|
||||||
|
# идут каталоги.
|
||||||
|
- name: "golangci-lint"
|
||||||
|
glob: "*.go"
|
||||||
|
run: |
|
||||||
|
dirs=$(printf '%s\n' {staged_files} | xargs -r -n1 dirname | sort -u)
|
||||||
|
golangci-lint run $dirs
|
||||||
|
|
||||||
|
- name: "shellcheck"
|
||||||
|
glob: "*.sh"
|
||||||
|
run: "shellcheck {staged_files}"
|
||||||
|
|
||||||
|
# Подавления те же, что у шага гейта, и по тем же причинам — Taskfile.yml,
|
||||||
|
# задача `dockerfile`.
|
||||||
|
- name: "hadolint"
|
||||||
|
glob: "Dockerfile"
|
||||||
|
run: "hadolint --ignore DL3007 --ignore DL3018 {staged_files}"
|
||||||
|
|
||||||
- name: "gitleaks"
|
- name: "gitleaks"
|
||||||
run: "gitleaks git --staged"
|
run: "gitleaks git --staged"
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// Package scripts — проверки скриптов репозитория. Рабочего кода на Go в нём
|
||||||
|
// нет: пакет существует ради того, чтобы `go test ./...` гонял и shell.
|
||||||
|
//
|
||||||
|
// Норма шага сверки версий — openspec/specs/toolchain/spec.md. Каждый её
|
||||||
|
// сценарий проверяется здесь мутацией: дерево-образец собирается во временном
|
||||||
|
// каталоге, портится ровно одним способом, и от скрипта требуется объявленный
|
||||||
|
// исход. Прежде сценарии подтверждались разовыми ручными прогонами — после
|
||||||
|
// первой правки образца они перестали бы выполняться молча.
|
||||||
|
package scripts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Дерево-образец: все четыре места называют одну версию.
|
||||||
|
//
|
||||||
|
// `CLAUDE.md` держит второе число **за** разделом стека намеренно: так выглядит
|
||||||
|
// правдивая строка о закрытом долге, и норма велит её не читать.
|
||||||
|
var fixture = map[string]string{
|
||||||
|
"go.mod": "module example\n\ngo 1.26.0\n",
|
||||||
|
"Dockerfile": "FROM docker.io/library/golang:1.26-alpine AS builder\n" +
|
||||||
|
"RUN true\n\n" +
|
||||||
|
"FROM docker.io/library/alpine:3.22\n",
|
||||||
|
"CLAUDE.md": "# CLAUDE.md\n\n" +
|
||||||
|
"## Стек\n\nGo 1.26, встроенная PocketBase.\n\n" +
|
||||||
|
"## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
|
||||||
|
"README.md": "# transcriber\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
// allPlaces — все четыре места и число каждого: этого требует норма от
|
||||||
|
// сообщения о расхождении. Числа два, потому что разошедшееся место называет
|
||||||
|
// своё.
|
||||||
|
var allPlaces = []string{"go.mod", "Dockerfile", "CLAUDE.md", "README.md", "1.26", "1.25"}
|
||||||
|
|
||||||
|
const (
|
||||||
|
exitOK = 0
|
||||||
|
exitDrift = 1
|
||||||
|
exitUsage = 2
|
||||||
|
exitEnviron = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestСверкаВерсийПоСценариямНормы(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
// mutate портит дерево-образец; nil — дерево не портится.
|
||||||
|
mutate func(t *testing.T, root string)
|
||||||
|
// args — аргументы скрипта.
|
||||||
|
args []string
|
||||||
|
// dir — рабочий каталог прогона относительно корня дерева.
|
||||||
|
dir string
|
||||||
|
want int
|
||||||
|
// says — что обязано прозвучать в сообщении.
|
||||||
|
says string
|
||||||
|
// saysAll — что обязано прозвучать всё разом. Норма требует от сообщения
|
||||||
|
// о расхождении **все четыре места и число каждого**: в дефекте
|
||||||
|
// 2026-08-12 три места из четырёх говорили одно и то же, и неверными
|
||||||
|
// были именно они — по сообщению о паре человек чинит не то место.
|
||||||
|
saysAll []string
|
||||||
|
// saysNot — чего в сообщении быть не должно.
|
||||||
|
saysNot string
|
||||||
|
}{
|
||||||
|
{name: "все четыре места названы одинаково", want: exitOK},
|
||||||
|
{
|
||||||
|
name: "патч сборочного образа отличается законно",
|
||||||
|
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26.5-alpine"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "база сборочного образа сменилась",
|
||||||
|
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26-bookworm"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "сборочный образ собран в два слоя",
|
||||||
|
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.26-alpine AS tools"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "слои сборочного образа разошлись между собой",
|
||||||
|
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.25-alpine AS tools"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "Dockerfile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "разошёлся сборочный образ",
|
||||||
|
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.25-alpine"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "разошлись",
|
||||||
|
saysAll: allPlaces,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "разошлось требование модуля",
|
||||||
|
mutate: replace("go.mod", "go 1.26.0", "go 1.25.0"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "разошлись",
|
||||||
|
saysAll: allPlaces,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "разошлась памятка",
|
||||||
|
mutate: replace("CLAUDE.md", "Go 1.26, встроенная", "Go 1.25, встроенная"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "разошлись",
|
||||||
|
saysAll: allPlaces,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "разошёлся README",
|
||||||
|
mutate: replace("README.md", "Go 1.26 и ffmpeg", "Go 1.25 и ffmpeg"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "разошлись",
|
||||||
|
saysAll: allPlaces,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "раздел стека называет версию дважды",
|
||||||
|
mutate: replace("CLAUDE.md", "встроенная PocketBase.", "встроенная PocketBase, всё та же Go 1.26."),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "больше одного раза",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "число за пределами раздела стека не читается",
|
||||||
|
mutate: replace("CLAUDE.md", "Go 1.24 — долг закрыт.", "Go 1.24 и Go 1.23 — долги закрыты."),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "заголовок раздела встретился внутри блока кода",
|
||||||
|
mutate: replace("README.md", "## Технологии\n\nGo 1.26 и ffmpeg.\n",
|
||||||
|
"## Пример\n\n```md\n## Технологии\n\nGo 1.19 из примера.\n```\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "раздел стека закрыт заголовком верхнего уровня",
|
||||||
|
mutate: replace("CLAUDE.md", "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
|
||||||
|
"# Приложение\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "раздела стека нет вовсе",
|
||||||
|
mutate: replace("CLAUDE.md", "## Стек", "## Инструменты"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "нет раздела",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "версия не названа там, где должна быть",
|
||||||
|
mutate: replace("README.md", "Go 1.26 и ffmpeg.", "ffmpeg и всё остальное."),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "не называет версию",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "модуль объявляет версию пятым местом",
|
||||||
|
mutate: replace("go.mod", "go 1.26.0", "go 1.26.0\n\ntoolchain go1.27.0"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "toolchain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "места нет вовсе",
|
||||||
|
mutate: remove("README.md"),
|
||||||
|
want: exitEnviron,
|
||||||
|
says: "нет файла",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "место существует, но не читается",
|
||||||
|
mutate: unreadable("README.md"),
|
||||||
|
want: exitEnviron,
|
||||||
|
says: "нечитаем",
|
||||||
|
saysNot: "не называет версию",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "шаг запущен не из корня проекта",
|
||||||
|
dir: "scripts",
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "шагу переданы аргументы",
|
||||||
|
args: []string{"--base", "origin/master"},
|
||||||
|
want: exitUsage,
|
||||||
|
says: "Использование",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
root := treeWithScript(t)
|
||||||
|
if c.mutate != nil {
|
||||||
|
c.mutate(t, root)
|
||||||
|
}
|
||||||
|
code, out := runScript(t, root, c.dir, c.args)
|
||||||
|
if code != c.want {
|
||||||
|
t.Errorf("код возврата %d, ожидался %d\nвывод:\n%s", code, c.want, out)
|
||||||
|
}
|
||||||
|
if c.says != "" && !strings.Contains(out, c.says) {
|
||||||
|
t.Errorf("в сообщении нет %q\nвывод:\n%s", c.says, out)
|
||||||
|
}
|
||||||
|
for _, want := range c.saysAll {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("сообщение не называет %q\nвывод:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.saysNot != "" && strings.Contains(out, c.saysNot) {
|
||||||
|
t.Errorf("в сообщении есть лишнее %q\nвывод:\n%s", c.saysNot, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Норма требует, чтобы исход был функцией коммита, а не машины: скрипт не
|
||||||
|
// спрашивает установленный инструмент. Проверяется это прогоном без `go` в
|
||||||
|
// `PATH` — исход обязан не измениться.
|
||||||
|
func TestИсходНеЗависитОтУстановленногоGo(t *testing.T) {
|
||||||
|
root := treeWithScript(t)
|
||||||
|
|
||||||
|
withGo, outWith := runScript(t, root, "", nil)
|
||||||
|
if withGo != exitOK {
|
||||||
|
t.Fatalf("дерево-образец обязано сходиться, а код %d:\n%s", withGo, outWith)
|
||||||
|
}
|
||||||
|
|
||||||
|
goBin, err := exec.LookPath("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("go не найден в PATH — проверять нечего")
|
||||||
|
}
|
||||||
|
var kept []string
|
||||||
|
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||||
|
if dir != filepath.Dir(goBin) {
|
||||||
|
kept = append(kept, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withoutGo, outWithout := runScript(t, root, "", nil, "PATH="+strings.Join(kept, string(os.PathListSeparator)))
|
||||||
|
if withoutGo != withGo {
|
||||||
|
t.Errorf("без go в PATH код %d, с ним %d\nвывод:\n%s", withoutGo, withGo, outWithout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скрипт не зовёт ни `go`, ни `docker`, ни сеть — это читается из его текста, и
|
||||||
|
// правило держит именно текст: прогон без сети в наборе проверок недоступен.
|
||||||
|
func TestСкриптНеЗоветНиGoНиDocker(t *testing.T) {
|
||||||
|
body, err := os.ReadFile("check-go-version.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю скрипт: %v", err)
|
||||||
|
}
|
||||||
|
// Границы слова обязательны: имя `read_dockerfile` и переменная `dockerfile`
|
||||||
|
// законны — читается файл, а не зовётся демон.
|
||||||
|
code := withoutComments(string(body))
|
||||||
|
for _, forbidden := range []string{`go\s+version`, `go\s+env`, `GOTOOLCHAIN`, `\bdocker\b`, `\bcurl\b`, `\bwget\b`} {
|
||||||
|
if regexp.MustCompile(forbidden).FindString(code) != "" {
|
||||||
|
t.Errorf("скрипт зовёт %s вне комментария: исход перестаёт быть функцией коммита", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Помощники --------------------------------------------------------------
|
||||||
|
|
||||||
|
// treeWithScript собирает дерево-образец и кладёт в него сам скрипт: корень он
|
||||||
|
// считает от своего расположения, поэтому проверяется копия внутри дерева.
|
||||||
|
func treeWithScript(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, body := range fixture {
|
||||||
|
write(t, filepath.Join(root, name), body, 0o644)
|
||||||
|
}
|
||||||
|
script, err := os.ReadFile("check-go-version.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю скрипт: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "scripts"), 0o755); err != nil {
|
||||||
|
t.Fatalf("завожу каталог scripts: %v", err)
|
||||||
|
}
|
||||||
|
write(t, filepath.Join(root, "scripts", "check-go-version.sh"), string(script), 0o755)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// runScript гоняет скрипт и отдаёт код возврата с объединённым выводом.
|
||||||
|
// `env` — добавка к окружению прогона, `args` — аргументы скрипта.
|
||||||
|
func runScript(t *testing.T, root, dir string, args []string, env ...string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("sh", append([]string{filepath.Join(root, "scripts", "check-go-version.sh")}, args...)...)
|
||||||
|
cmd.Dir = filepath.Join(root, dir)
|
||||||
|
if len(env) > 0 {
|
||||||
|
cmd.Env = append(os.Environ(), env...)
|
||||||
|
}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
code := 0
|
||||||
|
if err != nil {
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) {
|
||||||
|
t.Fatalf("прогон скрипта: %v", err)
|
||||||
|
}
|
||||||
|
code = exit.ExitCode()
|
||||||
|
}
|
||||||
|
return code, string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replace(file, old, new string) func(*testing.T, string) {
|
||||||
|
return func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(root, file)
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю %s: %v", file, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), old) {
|
||||||
|
t.Fatalf("в образце %s нет %q: мутация потеряла предмет", file, old)
|
||||||
|
}
|
||||||
|
write(t, path, strings.Replace(string(body), old, new, 1), 0o644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(file string) func(*testing.T, string) {
|
||||||
|
return func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.Remove(filepath.Join(root, file)); err != nil {
|
||||||
|
t.Fatalf("убираю %s: %v", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unreadable(file string) func(*testing.T, string) {
|
||||||
|
return func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(root, file)
|
||||||
|
if err := os.Chmod(path, 0o000); err != nil {
|
||||||
|
t.Fatalf("снимаю права с %s: %v", file, err)
|
||||||
|
}
|
||||||
|
// Права возвращаются, иначе уборка временного каталога отказала бы.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chmod(path, 0o644); err != nil {
|
||||||
|
t.Errorf("возвращаю права %s: %v", file, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if body, err := os.ReadFile(path); err == nil {
|
||||||
|
t.Skipf("файл читается и без прав (%d байт) — прогон под root?", len(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(t *testing.T, path, body string, perm os.FileMode) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(body), perm); err != nil {
|
||||||
|
t.Fatalf("пишу %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutComments снимает строки-комментарии: слово в объяснении вызовом не
|
||||||
|
// является, а объяснения в этом скрипте длиннее самого кода.
|
||||||
|
func withoutComments(body string) string {
|
||||||
|
var kept []string
|
||||||
|
for line := range strings.SplitSeq(body, "\n") {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user