From 1dee68073d20d116e180e925c442dff8029575ed Mon Sep 17 00:00:00 2001 From: Format_C_eft Date: Wed, 27 May 2026 13:04:56 +0500 Subject: [PATCH] feat: harden git-update workflow Update the project to Go 1.26 and refresh the lint profile. Make batch Git operations safer with porcelain status checks, fast-forward-only pulls, bounded parallelism, and proper CLI exit codes. Add CI and release workflows with generated GitHub release notes and release assets. Expand test coverage across the CLI, command runner, config, and shell executor, and rewrite the README documentation. --- .github/release.yml | 32 ++ .github/workflows/build.yml | 26 -- .github/workflows/ci.yml | 73 ++++ .github/workflows/lint.yml | 28 -- .github/workflows/release.yml | 98 +++++ .github/workflows/tests.yml | 30 -- .golangci.yml | 40 +- LICENSE | 2 +- Makefile | 13 +- README.md | 233 ++++++++++- build.mk | 5 +- cmd/git-update/main.go | 8 +- cmd/git-update/main_test.go | 50 +++ go.mod | 2 +- internal/cmd/dto.go | 1 - internal/cmd/dto_test.go | 63 +++ internal/cmd/run.go | 33 +- internal/cmd/run_test.go | 362 ++++++++++++++++++ internal/config/dto.go | 6 +- internal/config/dto_test.go | 58 +++ internal/config/flags.go | 8 +- internal/shell_executor/shell_executor.go | 1 + .../shell_executor/shell_executor_test.go | 86 +++++ 23 files changed, 1106 insertions(+), 152 deletions(-) create mode 100644 .github/release.yml delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/tests.yml create mode 100644 cmd/git-update/main_test.go create mode 100644 internal/cmd/dto_test.go create mode 100644 internal/cmd/run_test.go create mode 100644 internal/config/dto_test.go create mode 100644 internal/shell_executor/shell_executor_test.go diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..34144ba --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,32 @@ +changelog: + exclude: + labels: + - ignore-for-release + authors: + - dependabot + categories: + - title: Breaking Changes + labels: + - breaking-change + - title: Features + labels: + - feature + - enhancement + - title: Fixes + labels: + - bug + - fix + - title: Documentation + labels: + - documentation + - docs + - title: Maintenance + labels: + - ci + - chore + - dependencies + - refactoring + - tests + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index ee4e3d6..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: build - -on: - push: - tags: - - v* - branches: - - master - pull_request: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install Go - uses: actions/setup-go@v3 - with: - go-version: 1.22.x - - - name: Build - shell: bash - run: make build \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..db266a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: ci + +on: + push: + branches-ignore: + - master + pull_request: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6.0.2 + + - name: Install Go + uses: actions/setup-go@v6.4.0 + with: + go-version-file: go.mod + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9.2.1 + with: + version: v2.12.2 + args: --config=.golangci.yml ./... + + test: + name: test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6.0.2 + + - name: Install Go + uses: actions/setup-go@v6.4.0 + with: + go-version-file: go.mod + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: Test + shell: bash + run: make test + + build: + name: build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6.0.2 + + - name: Install Go + uses: actions/setup-go@v6.4.0 + with: + go-version-file: go.mod + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: Build + shell: bash + run: make build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 425c6cf..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: lint - -on: - push: - tags: - - v* - branches: - - master - pull_request: - branches: - - master - -jobs: - lint: - name: lint-full - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install Go - uses: actions/setup-go@v3 - with: - go-version: 1.22.x - - - name: golangci-lint - uses: golangci/golangci-lint-action@v3 - with: - version: latest \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..23e1625 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: release + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: write + +jobs: + verify: + name: verify + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6.0.2 + + - name: Install Go + uses: actions/setup-go@v6.4.0 + with: + go-version-file: go.mod + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9.2.1 + with: + version: v2.12.2 + args: --config=.golangci.yml ./... + + - name: Test + shell: bash + run: make test + + - name: Build + shell: bash + run: make build + + release: + name: release + if: github.ref == 'refs/heads/master' + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v6.4.0 + with: + go-version-file: go.mod + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: Prepare release version + id: version + shell: bash + run: | + tag="v0.1.${GITHUB_RUN_NUMBER}" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + + - name: Build release assets + shell: bash + run: | + mkdir -p dist + + build_time="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + commit_hash="${GITHUB_SHA::7}" + ldflags="-X 'github.com/Format-C-eft/git-update/internal/config.branch=${GITHUB_REF_NAME}' -X 'github.com/Format-C-eft/git-update/internal/config.commitHash=${commit_hash}' -X 'github.com/Format-C-eft/git-update/internal/config.timeBuild=${build_time}'" + + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + goos="${target%/*}" + goarch="${target#*/}" + output="dist/git-update-${goos}-${goarch}" + + CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" \ + go build -ldflags "${ldflags}" -o "${output}" ./cmd/git-update + done + + sha256sum dist/* > dist/checksums.txt + + - name: Create GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ steps.version.outputs.tag }}" \ + dist/* \ + --target "${GITHUB_SHA}" \ + --generate-notes \ + --fail-on-no-commits diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 286fc4f..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: tests - -on: - push: - tags: - - v* - branches: - - master - pull_request: - branches: - - master - -jobs: - test: - strategy: - matrix: - go-version: [1.22.x] - os: [ubuntu-latest] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install Go - uses: actions/setup-go@v3 - with: - go-version: 1.22.x - - - name: Test - shell: bash - run: make test diff --git a/.golangci.yml b/.golangci.yml index 988991e..c526cd5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,27 +6,40 @@ run: linters: default: none enable: - - dupl - - durationcheck + - copyloopvar - errorlint - errcheck - goconst + - gocritic - gosec - govet - ineffassign + - nilerr + - nilnil + - nolintlint - revive - staticcheck + - unconvert - unused - wsl_v5 settings: - dupl: - threshold: 100 goconst: min-len: 2 min-occurrences: 2 govet: enable: - shadow + nolintlint: + require-explanation: true + require-specific: true + revive: + rules: + - name: exported + disabled: true + - name: package-comments + disabled: true + - name: var-naming + disabled: true wsl_v5: enable: - assign @@ -51,25 +64,6 @@ linters: - trailing-whitespace exclusions: generated: lax - rules: - - path: (.+)\.go$ - text: G104 - - path: (.+)\.go$ - text: G115 - - path: (.+)\.go$ - text: G204 - - path: (.+)\.go$ - text: should have a package comment - - path: (.+)\.go$ - text: should have comment or be unexported - - path: (.+)\.go$ - text: comment on exported const - - path: (.+)\.go$ - text: should have comment \(or a comment on this block\) - - path: (.+)\.go$ - text: don't use an underscore in package name - - path: (.+)\.go$ - text: package-comments paths: - bin - vendor diff --git a/LICENSE b/LICENSE index bbd7940..4daeec0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 platform +Copyright (c) 2021-2026 Format-C-eft Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 5ba8a23..f121ff8 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,8 @@ CURDIR_ESCAPE:=$(subst $(space),\ ,$(CURDIR)) LOCAL_BIN:=$(CURDIR_ESCAPE)/bin LINT_BIN:=$(LOCAL_BIN)/golangci-lint -LINT_VERSION:=2.5.0 +LINT_VERSION:=2.12.2 +INSTALLED_LINT_VERSION:=$(shell if [ -x "$(LINT_BIN)" ]; then "$(LINT_BIN)" version 2>/dev/null | sed -E 's/.* version ([^ ]+) .*/\1/'; fi) ###### TEST ###### .PHONY: test @@ -21,15 +22,15 @@ test: ###### LINT ###### .PHONY: install-lint install-lint: -ifeq ($(wildcard $(LINT_BIN)),) +ifneq ("$(INSTALLED_LINT_VERSION)","$(LINT_VERSION)") $(info Installing golangci-lint v$(LINT_VERSION)) GOBIN=$(LOCAL_BIN) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v$(LINT_VERSION) # Устанавливаем текущий путь для исполняемого файла линтера. else - $(info Golangci-lint is already installed to $(LINT_VERSION)) + $(info Golangci-lint v$(LINT_VERSION) is already installed) endif -PHONY: lint +.PHONY: lint lint: install-lint $(info Running lint against changed files...) $(LINT_BIN) run \ @@ -37,10 +38,10 @@ lint: install-lint --config=.golangci.yml \ ./... -PHONY: lint-full +.PHONY: lint-full lint-full: install-lint $(info Running lint against all project files...) $(LINT_BIN) run \ --config=.golangci.yml \ ./... -###### LINT ###### \ No newline at end of file +###### LINT ###### diff --git a/README.md b/README.md index a8ccce1..5b86159 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,219 @@ # git-update -# Описание - Программа создана благодаря лени и для ускорения актуализации всех репозиториев - находящихся в одном корневом каталоге. - -# Требования - Операционная система на MacOS или Linux, Windows тоже подойдет пожалуй, но не тестировалось. - -# Использование - Ключи запуска: - - dir - Путь к корневому каталогу с проектами, по умолчанию "../" - - branch - Наименование корневой ветки: master/main - - all - Выполнить все шаги checkout master, fetch, pull, по умолчанию выключено - - checkout - Выполнить git checkout на корневую ветку, по умолчанию выключено - - fetch - Выполнить git fetch --prune --prune-tags, по умолчанию выключено - - pull - Выполнить git pull, по умолчанию выключено - - execute_timeout - Максимальное время выполнения всех действий над допкаталогом, по умолчанию 30сек +`git-update` - небольшая CLI-утилита для массового обновления Git-репозиториев, которые лежат в одном общем каталоге. + +Например, если у вас есть директория `~/projects`, а внутри нее десятки репозиториев: + +```text +~/projects/ + api/ + frontend/ + cli-tools/ + experiments/ +``` + +то `git-update` может пройти по всем непосредственным подкаталогам и выполнить для каждого нужные Git-действия: `checkout`, `fetch`, `pull`. + +Утилита полезна, когда хочется быстро привести пачку локальных репозиториев к актуальному состоянию без ручного захода в каждый каталог. + +## Как Работает + +Для каждого подкаталога внутри `-dir` программа: + +1. Проверяет состояние репозитория командой `git status --porcelain=v1`. +2. Если есть незакоммиченные изменения, пропускает репозиторий. +3. Если передан `-reset-hard`, перед дальнейшими действиями выполняет `git reset --hard`. +4. По выбранным флагам выполняет: + - `git checkout ` + - `git fetch --prune --prune-tags` + - `git pull --ff-only` +5. Печатает лог обработки по каждому каталогу. + +Обрабатываются только непосредственные подкаталоги указанной директории. Рекурсивного обхода вложенных папок нет. + +## Осторожно + +Флаг `-reset-hard` удаляет локальные незакоммиченные изменения в репозитории. Используйте его только когда точно понимаете, что эти изменения не нужны. + +`git pull` выполняется как `git pull --ff-only`. Это безопаснее для массового обновления: утилита не будет создавать merge commit. Если локальная ветка разошлась с удаленной, репозиторий будет отмечен ошибкой в логе. + +## Требования + +- Go `1.26` или новее для сборки из исходников. +- Установленный `git`. +- macOS или Linux. Windows не является основной целевой платформой и отдельно не проверялась. + +## Сборка + +```bash +make build +``` + +После сборки бинарный файл будет доступен по пути: + +```bash +./bin/git-update +``` + +Проверить версию: + +```bash +./bin/git-update -version +``` + +## Быстрый Старт + +Обновить все репозитории в `~/projects`: переключиться на `master`, выполнить `fetch` и затем `pull --ff-only`. + +```bash +./bin/git-update -dir ~/projects -all +``` + +То же самое, но для ветки `main`: + +```bash +./bin/git-update -dir ~/projects -branch main -all +``` + +Только получить изменения с удаленных репозиториев без переключения ветки и без pull: + +```bash +./bin/git-update -dir ~/projects -fetch +``` + +Переключить все чистые репозитории на `main`: + +```bash +./bin/git-update -dir ~/projects -branch main -checkout +``` + +Выполнить fast-forward pull для всех чистых репозиториев: + +```bash +./bin/git-update -dir ~/projects -pull +``` + +## Примеры + +### Полное Обновление Рабочей Папки + +```bash +./bin/git-update \ + -dir ~/projects \ + -branch main \ + -all +``` + +Эквивалентно набору действий: + +```bash +git checkout main +git fetch --prune --prune-tags +git pull --ff-only +``` + +### Аккуратное Обновление Без Сброса Изменений + +```bash +./bin/git-update -dir ~/projects -branch main -all +``` + +Если в каком-то репозитории есть незакоммиченные изменения, он будет пропущен. Остальные репозитории продолжат обрабатываться. + +### Принудительный Сброс Локальных Изменений + +```bash +./bin/git-update \ + -dir ~/projects \ + -branch main \ + -all \ + -reset-hard +``` + +Перед `checkout`, `fetch` и `pull` программа выполнит: + +```bash +git reset --hard +``` + +Это удобно для одноразового обслуживания локальных копий, но опасно для рабочих репозиториев с незакоммиченными изменениями. + +### Ограничение Параллельности + +По умолчанию одновременно обрабатывается до 4 репозиториев. + +```bash +./bin/git-update -dir ~/projects -all -parallel 2 +``` + +Это полезно, если репозиториев много или не хочется сильно нагружать сеть, диск и файловые дескрипторы. + +### Подробный Лог Команд + +```bash +./bin/git-update -dir ~/projects -all -verbose +``` + +С `-verbose` в лог добавляется вывод выполненных Git-команд. + +## Флаги + +| Флаг | По умолчанию | Описание | +| --- | --- | --- | +| `-dir` | `../` | Каталог, внутри которого лежат репозитории. Обрабатываются только непосредственные подкаталоги. | +| `-branch` | `master` | Ветка, на которую нужно переключаться при использовании `-checkout` или `-all`. Частые значения: `master`, `main`, `develop`. | +| `-all` | `false` | Включает сразу `-checkout`, `-fetch` и `-pull`. | +| `-checkout` | `false` | Выполнить `git checkout `. | +| `-fetch` | `false` | Выполнить `git fetch --prune --prune-tags`. | +| `-pull` | `false` | Выполнить `git pull --ff-only`. | +| `-reset-hard` | `false` | Если в репозитории есть незакоммиченные изменения, выполнить `git reset --hard` и продолжить обработку. Без этого флага грязные репозитории пропускаются. | +| `-parallel` | `4` | Максимальное число репозиториев, которые обрабатываются одновременно. Значение должно быть больше `0`. | +| `-execute_timeout` | `30s` | Максимальное время обработки одного репозитория. Общий timeout рассчитывается как `число_репозиториев * execute_timeout`. | +| `-verbose` | `false` | Добавлять в лог подробный вывод Git-команд. | +| `-version` | `false` | Показать информацию о сборке и завершить работу. | + +Минимально нужно указать хотя бы одно действие: `-checkout`, `-fetch`, `-pull` или `-all`. Если все действия выключены, программа завершится с ошибкой. + +## Логи И Коды Завершения + +При успешной обработке программа завершается с кодом `0`. + +Если произошла ошибка верхнего уровня, например указан пустой путь или не выбрано ни одного действия, сообщение печатается в `stderr`, а программа завершается с кодом `1`. + +Ошибки отдельных репозиториев записываются в лог этого репозитория. Обработка остальных каталогов продолжается. + +Пример фрагмента лога: + +```text +12:34:56.789: /Users/me/projects/api: start processing +12:34:56.900: /Users/me/projects/api: success: git fetch +12:34:57.100: /Users/me/projects/api: success: git pull --ff-only +12:34:57.101: /Users/me/projects/api: stop processing +-------------------------------------------------------------------- +``` + +## Разработка + +Запустить тесты: + +```bash +make test +``` + +Запустить линтер по изменениям относительно `origin/master`: + +```bash +make lint +``` + +Запустить полный линтер по проекту: + +```bash +make lint-full +``` + +Собрать бинарный файл: + +```bash +make build +``` diff --git a/build.mk b/build.mk index 2a6bc33..c9ccc35 100644 --- a/build.mk +++ b/build.mk @@ -1,6 +1,7 @@ +GO_MIN_VERSION:=1.26 GO_VERSION_SHORT:=$(shell echo `go version` | sed -E 's/.* go(.*) .*/\1/g') -ifneq ("1.23","$(shell printf "$(GO_VERSION_SHORT)\n1.23" | sort -V | head -1)") - $(error NEED GO VERSION >= 1.23. Found: $(GO_VERSION_SHORT)) +ifneq ("$(GO_MIN_VERSION)","$(shell printf "$(GO_VERSION_SHORT)\n$(GO_MIN_VERSION)" | sort -V | head -1)") + $(error NEED GO VERSION >= $(GO_MIN_VERSION). Found: $(GO_VERSION_SHORT)) endif ##################### PROJECT RELATED VARIABLES ##################### diff --git a/cmd/git-update/main.go b/cmd/git-update/main.go index 07d1e01..01c63c4 100644 --- a/cmd/git-update/main.go +++ b/cmd/git-update/main.go @@ -2,20 +2,24 @@ package main import ( "fmt" + "os" "github.com/Format-C-eft/git-update/internal/cmd" "github.com/Format-C-eft/git-update/internal/config" ) func main() { + config.ParseFlags() + if config.FlagVersion { showVersion() return } if errRun := cmd.Run(); errRun != nil { - fmt.Println(errRun.Error()) - return + _, _ = fmt.Fprintln(os.Stderr, errRun.Error()) + + os.Exit(1) } } diff --git a/cmd/git-update/main_test.go b/cmd/git-update/main_test.go new file mode 100644 index 0000000..711547f --- /dev/null +++ b/cmd/git-update/main_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "bytes" + "errors" + "os" + "os/exec" + "strings" + "testing" +) + +func TestMainWritesRunErrorToStderrAndExitsNonZero(t *testing.T) { + if os.Getenv("GO_WANT_MAIN_HELPER_PROCESS") == "1" { + main() + + return + } + + //nolint:gosec // Test executes the current test binary as a helper process. + cmd := exec.Command(os.Args[0], "-test.run=TestMainWritesRunErrorToStderrAndExitsNonZero") + + cmd.Env = append(os.Environ(), "GO_WANT_MAIN_HELPER_PROCESS=1") + + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err == nil { + t.Fatal("helper process exited successfully, want failure") + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("helper process error = %v, want exec.ExitError", err) + } + + if exitErr.ExitCode() != 1 { + t.Fatalf("exit code = %d, want %d", exitErr.ExitCode(), 1) + } + + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + + if !strings.Contains(stderr.String(), "all actions are disabled") { + t.Fatalf("stderr = %q, want run error", stderr.String()) + } +} diff --git a/go.mod b/go.mod index e7c575d..ab42849 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/Format-C-eft/git-update -go 1.25 +go 1.26 diff --git a/internal/cmd/dto.go b/internal/cmd/dto.go index 3f6ce52..9c88768 100644 --- a/internal/cmd/dto.go +++ b/internal/cmd/dto.go @@ -10,7 +10,6 @@ import ( const ( layoutDateFormat = "15:04:05.999" - gitStatusOk = "nothing to commit, working tree clean" ) type ( diff --git a/internal/cmd/dto_test.go b/internal/cmd/dto_test.go new file mode 100644 index 0000000..670ffb1 --- /dev/null +++ b/internal/cmd/dto_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "strings" + "testing" + "time" + + "github.com/Format-C-eft/git-update/internal/config" +) + +const testRepoName = "repo" + +func TestResultLogAddLogRespectsVerboseFlag(t *testing.T) { + restoreConfig(t) + + result := resultLog{dir: testRepoName} + + config.FlagVerbose = false + + result.AddLog("plain", "hidden") + + if len(result.logs) != 1 { + t.Fatalf("logs length = %d, want %d", len(result.logs), 1) + } + + if result.logs[0].messages != "plain" { + t.Fatalf("first log message = %q, want %q", result.logs[0].messages, "plain") + } + + config.FlagVerbose = true + + result.AddLog("plain again", "visible") + + if len(result.logs) != 3 { + t.Fatalf("logs length = %d, want %d", len(result.logs), 3) + } + + if result.logs[2].messages != "verbose: visible" { + t.Fatalf("verbose log message = %q, want %q", result.logs[2].messages, "verbose: visible") + } +} + +func TestResultLogString(t *testing.T) { + first := time.Date(2026, 5, 27, 12, 34, 56, 789*int(time.Millisecond), time.UTC) + second := first.Add(time.Millisecond) + result := resultLog{ + dir: testRepoName, + logs: []logs{ + {Time: first, messages: "start"}, + {Time: second, messages: "stop"}, + }, + } + + got := result.String() + want := strings.Join([]string{ + "12:34:56.789: " + testRepoName + ": start", + "12:34:56.79: " + testRepoName + ": stop", + }, "\n") + + if got != want { + t.Fatalf("String() = %q, want %q", got, want) + } +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 8ed9302..924ac81 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -32,6 +32,10 @@ func Run() error { return errors.New("all actions are disabled") } + if config.FlagParallel < 1 { + return errors.New("parallel must be greater than zero") + } + listDir, err := getListOfDirectories() if err != nil { return fmt.Errorf("getListOfDirectories err: %w", err) @@ -44,19 +48,26 @@ func Run() error { ch := make(chan resultLog, len(listDir)) defer close(ch) + sem := make(chan struct{}, config.FlagParallel) + for _, dir := range listDir { - go processDir(dir, ch) + go func(dir string) { + sem <- struct{}{} + defer func() { <-sem }() + + processDir(dir, ch) + }(dir) } - execTimeout := *config.FlagExecuteTimeout + execTimeout := config.FlagExecuteTimeout execTimeout = time.Duration(int64(len(listDir)) * execTimeout.Nanoseconds()) ctx, cancel := context.WithTimeout(context.Background(), execTimeout) defer cancel() var ( - countDone atomic.Int32 - countAll = int32(len(listDir)) + countDone atomic.Int64 + countAll = int64(len(listDir)) ) for { @@ -100,16 +111,16 @@ func processDir(dir string, ch chan resultLog) { ch <- resultLogs }() - ctx, cancelFn := context.WithTimeout(context.Background(), *config.FlagExecuteTimeout) + ctx, cancelFn := context.WithTimeout(context.Background(), config.FlagExecuteTimeout) defer cancelFn() - res, errRun := shell_executor.Run(ctx, dir, "git", "status") + res, errRun := shell_executor.Run(ctx, dir, "git", "status", "--porcelain=v1") if errRun != nil { - resultLogs.AddLog("skipped: error: execute git status", errRun.Error()) + resultLogs.AddLog("skipped: error: execute git status --porcelain=v1", errRun.Error()) return } - if !strings.Contains(res, gitStatusOk) { + if strings.TrimSpace(res) != "" { resultLogs.AddLog("find uncommitted changes", "") if !config.FlagResetHard { @@ -147,12 +158,12 @@ func processDir(dir string, ch chan resultLog) { } if config.FlagPull { - result, err := shell_executor.Run(ctx, dir, "git", "pull") + result, err := shell_executor.Run(ctx, dir, "git", "pull", "--ff-only") if err != nil { - resultLogs.AddLog("error: git pull ", err.Error()) + resultLogs.AddLog("error: git pull --ff-only", err.Error()) return } - resultLogs.AddLog("success: git pull ", result) + resultLogs.AddLog("success: git pull --ff-only", result) } } diff --git a/internal/cmd/run_test.go b/internal/cmd/run_test.go new file mode 100644 index 0000000..0aa8d7f --- /dev/null +++ b/internal/cmd/run_test.go @@ -0,0 +1,362 @@ +package cmd + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/Format-C-eft/git-update/internal/config" +) + +const testDefaultBranch = "master" + +func TestRunValidationErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) + wantErr string + }{ + { + name: "empty path", + setup: func(t *testing.T) { + restoreConfig(t) + + config.FlagDir = "" + config.FlagCheckout = true + }, + wantErr: "empty path to directory", + }, + { + name: "actions disabled", + setup: func(t *testing.T) { + restoreConfig(t) + + config.FlagDir = t.TempDir() + }, + wantErr: "all actions are disabled", + }, + { + name: "invalid parallel", + setup: func(t *testing.T) { + restoreConfig(t) + + config.FlagDir = t.TempDir() + config.FlagCheckout = true + config.FlagParallel = 0 + }, + wantErr: "parallel must be greater than zero", + }, + { + name: "no subdirectories", + setup: func(t *testing.T) { + restoreConfig(t) + + config.FlagDir = t.TempDir() + config.FlagCheckout = true + }, + wantErr: "directory does not contain subdirectories", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup(t) + + err := Run() + if err == nil { + t.Fatalf("Run returned nil error, want %q", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Run error = %q, want to contain %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestGetListOfDirectories(t *testing.T) { + restoreConfig(t) + + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "repo-a")) + mustMkdir(t, filepath.Join(root, "repo-b")) + mustWriteFile(t, filepath.Join(root, "README.md"), "not a directory") + + config.FlagDir = root + string(os.PathSeparator) + + got, err := getListOfDirectories() + if err != nil { + t.Fatalf("getListOfDirectories returned error: %v", err) + } + + sort.Strings(got) + + want := []string{ + filepath.Join(root, "repo-a"), + filepath.Join(root, "repo-b"), + } + + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("directories = %#v, want %#v", got, want) + } +} + +func TestRunCheckoutCleanRepository(t *testing.T) { + restoreConfig(t) + requireGit(t) + + root := t.TempDir() + repo := filepath.Join(root, testRepoName) + mustMkdir(t, repo) + initGitRepo(t, repo) + + config.FlagDir = root + config.FlagCheckout = true + config.FlagDefaultBranch = testDefaultBranch + + err := captureStdout(t, Run) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } +} + +func TestProcessDirSkipsDirtyRepositoryWithoutReset(t *testing.T) { + restoreConfig(t) + requireGit(t) + + repo := filepath.Join(t.TempDir(), testRepoName) + mustMkdir(t, repo) + initGitRepo(t, repo) + mustWriteFile(t, filepath.Join(repo, "dirty.txt"), "dirty") + + config.FlagCheckout = true + config.FlagDefaultBranch = testDefaultBranch + config.FlagResetHard = false + + result := runProcessDir(t, repo) + got := result.String() + + if !strings.Contains(got, "find uncommitted changes") { + t.Fatalf("processDir log = %q, want dirty repository message", got) + } + + if !strings.Contains(got, "skipped: there are uncommitted changes") { + t.Fatalf("processDir log = %q, want skip message", got) + } +} + +func TestProcessDirPullFastForwardOnlyUpdatesRepository(t *testing.T) { + restoreConfig(t) + + _, seed, repo := createRemoteWithClone(t) + + writeFileAndCommit(t, seed, "file.txt", "remote update\n", "remote update") + runGit(t, seed, "push", "origin", testDefaultBranch) + + config.FlagPull = true + + result := runProcessDir(t, repo) + + got := result.String() + if !strings.Contains(got, "success: git pull --ff-only") { + t.Fatalf("processDir log = %q, want successful fast-forward pull", got) + } + + //nolint:gosec // Test reads a file from a controlled temporary repository. + content, err := os.ReadFile(filepath.Join(repo, "file.txt")) + if err != nil { + t.Fatalf("read pulled file: %v", err) + } + + if string(content) != "remote update\n" { + t.Fatalf("pulled file content = %q, want %q", string(content), "remote update\n") + } +} + +func TestProcessDirPullFastForwardOnlyRejectsDivergedHistory(t *testing.T) { + restoreConfig(t) + + _, seed, repo := createRemoteWithClone(t) + + writeFileAndCommit(t, seed, "remote.txt", "remote\n", "remote update") + runGit(t, seed, "push", "origin", testDefaultBranch) + writeFileAndCommit(t, repo, "local.txt", "local\n", "local update") + + config.FlagPull = true + + result := runProcessDir(t, repo) + + got := result.String() + if !strings.Contains(got, "error: git pull --ff-only") { + t.Fatalf("processDir log = %q, want fast-forward-only error", got) + } +} + +func restoreConfig(t *testing.T) { + t.Helper() + + oldDir := config.FlagDir + oldAll := config.FlagAll + oldCheckout := config.FlagCheckout + oldFetch := config.FlagFetch + oldPull := config.FlagPull + oldResetHard := config.FlagResetHard + oldDefaultBranch := config.FlagDefaultBranch + oldVerbose := config.FlagVerbose + oldParallel := config.FlagParallel + oldExecuteTimeout := config.FlagExecuteTimeout + oldVersion := config.FlagVersion + + t.Cleanup(func() { + config.FlagDir = oldDir + config.FlagAll = oldAll + config.FlagCheckout = oldCheckout + config.FlagFetch = oldFetch + config.FlagPull = oldPull + config.FlagResetHard = oldResetHard + config.FlagDefaultBranch = oldDefaultBranch + config.FlagVerbose = oldVerbose + config.FlagParallel = oldParallel + config.FlagExecuteTimeout = oldExecuteTimeout + config.FlagVersion = oldVersion + }) + + config.FlagDir = "" + config.FlagAll = false + config.FlagCheckout = false + config.FlagFetch = false + config.FlagPull = false + config.FlagResetHard = false + config.FlagDefaultBranch = testDefaultBranch + config.FlagVerbose = false + config.FlagParallel = 1 + config.FlagExecuteTimeout = 5 * time.Second + config.FlagVersion = false +} + +func captureStdout(t *testing.T, fn func() error) error { + t.Helper() + + oldStdout := os.Stdout + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create stdout pipe: %v", err) + } + + os.Stdout = writer + defer func() { + os.Stdout = oldStdout + }() + + errRun := fn() + + if err := writer.Close(); err != nil { + t.Fatalf("close stdout writer: %v", err) + } + + if _, err := io.ReadAll(reader); err != nil { + t.Fatalf("read captured stdout: %v", err) + } + + if err := reader.Close(); err != nil { + t.Fatalf("close stdout reader: %v", err) + } + + return errRun +} + +func runProcessDir(t *testing.T, repo string) resultLog { + t.Helper() + + ch := make(chan resultLog, 1) + processDir(repo, ch) + + return <-ch +} + +func createRemoteWithClone(t *testing.T) (string, string, string) { + t.Helper() + requireGit(t) + + root := t.TempDir() + remote := filepath.Join(root, "remote.git") + seed := filepath.Join(root, "seed") + repo := filepath.Join(root, testRepoName) + + mustMkdir(t, remote) + mustMkdir(t, seed) + + runGit(t, remote, "init", "--bare") + initGitRepo(t, seed) + writeFileAndCommit(t, seed, "file.txt", "base\n", "initial commit") + runGit(t, seed, "remote", "add", "origin", remote) + runGit(t, seed, "push", "-u", "origin", testDefaultBranch) + runGit(t, remote, "symbolic-ref", "HEAD", "refs/heads/"+testDefaultBranch) + runGit(t, root, "clone", remote, repo) + + return remote, seed, repo +} + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + + runGit(t, dir, "init", "-b", testDefaultBranch) +} + +func writeFileAndCommit(t *testing.T, dir string, name string, content string, message string) { + t.Helper() + + mustWriteFile(t, filepath.Join(dir, name), content) + runGit(t, dir, "add", name) + runGit(t, dir, + "-c", "user.name=Test User", + "-c", "user.email=test@example.com", + "commit", + "-m", message, + ) +} + +func requireGit(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is required for this test") + } +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + + gitArgs := append([]string{"-C", dir}, args...) + //nolint:gosec // Tests execute git with controlled arguments. + cmd := exec.Command("git", gitArgs...) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(gitArgs, " "), err, output) + } + + return string(output) +} + +func mustMkdir(t *testing.T, path string) { + t.Helper() + + if err := os.MkdirAll(path, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } +} + +func mustWriteFile(t *testing.T, path string, content string) { + t.Helper() + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/config/dto.go b/internal/config/dto.go index 37263bf..38fc3bc 100644 --- a/internal/config/dto.go +++ b/internal/config/dto.go @@ -4,9 +4,9 @@ const AppName = "git-update" // Build information -ldflags . var ( - branch = "dev" //nolint - commitHash = "-" //nolint - timeBuild = "-" //nolint + branch = "dev" + commitHash = "-" + timeBuild = "-" ) type Version struct { diff --git a/internal/config/dto_test.go b/internal/config/dto_test.go new file mode 100644 index 0000000..1818230 --- /dev/null +++ b/internal/config/dto_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "testing" + "time" +) + +func TestGetVersion(t *testing.T) { + oldBranch := branch + oldCommitHash := commitHash + oldTimeBuild := timeBuild + + t.Cleanup(func() { + branch = oldBranch + commitHash = oldCommitHash + timeBuild = oldTimeBuild + }) + + branch = "test-branch" + commitHash = "abc123" + timeBuild = "2026-05-27T12:00:00+0500" + + got := GetVersion() + + if got.Name != AppName { + t.Fatalf("Name = %q, want %q", got.Name, AppName) + } + + if got.Branch != branch { + t.Fatalf("Branch = %q, want %q", got.Branch, branch) + } + + if got.CommitHash != commitHash { + t.Fatalf("CommitHash = %q, want %q", got.CommitHash, commitHash) + } + + if got.TimeBuild != timeBuild { + t.Fatalf("TimeBuild = %q, want %q", got.TimeBuild, timeBuild) + } +} + +func TestDefaultFlags(t *testing.T) { + if FlagDir != "../" { + t.Fatalf("FlagDir = %q, want %q", FlagDir, "../") + } + + if FlagDefaultBranch != "master" { + t.Fatalf("FlagDefaultBranch = %q, want %q", FlagDefaultBranch, "master") + } + + if FlagParallel != 4 { + t.Fatalf("FlagParallel = %d, want %d", FlagParallel, 4) + } + + if FlagExecuteTimeout != 30*time.Second { + t.Fatalf("FlagExecuteTimeout = %s, want %s", FlagExecuteTimeout, 30*time.Second) + } +} diff --git a/internal/config/flags.go b/internal/config/flags.go index f3ab6f3..3cbe7f8 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -14,8 +14,9 @@ var ( FlagResetHard bool FlagDefaultBranch string FlagVerbose bool + FlagParallel int - FlagExecuteTimeout *time.Duration + FlagExecuteTimeout time.Duration FlagVersion bool ) @@ -30,10 +31,13 @@ func init() { flag.BoolVar(&FlagPull, "pull", false, "Выполнить pull") flag.BoolVar(&FlagResetHard, "reset-hard", false, "При необходимости перед git checkout выполнить git reset --hard") flag.BoolVar(&FlagVerbose, "verbose", false, "Выводить результат выполнения команд") + flag.IntVar(&FlagParallel, "parallel", 4, "Максимальное число одновременно обрабатываемых каталогов") - FlagExecuteTimeout = flag.Duration("execute_timeout", time.Second*30, "Максимальное время обработки одного каталога") + flag.DurationVar(&FlagExecuteTimeout, "execute_timeout", time.Second*30, "Максимальное время обработки одного каталога") flag.BoolVar(&FlagVersion, "version", false, "Показать версию приложения") +} +func ParseFlags() { flag.Parse() } diff --git a/internal/shell_executor/shell_executor.go b/internal/shell_executor/shell_executor.go index 637ec63..5e45929 100644 --- a/internal/shell_executor/shell_executor.go +++ b/internal/shell_executor/shell_executor.go @@ -13,6 +13,7 @@ var regexpLineBreaks = regexp.MustCompile(`\r?\n`) func Run(ctx context.Context, dir string, cmdline ...string) (string, error) { var stderr, stdout bytes.Buffer + //nolint:gosec // Commands are passed as exec arguments without shell expansion. cmd := exec.CommandContext(ctx, cmdline[0], cmdline[1:]...) cmd.Dir = dir cmd.Stderr = &stderr diff --git a/internal/shell_executor/shell_executor_test.go b/internal/shell_executor/shell_executor_test.go new file mode 100644 index 0000000..950d55a --- /dev/null +++ b/internal/shell_executor/shell_executor_test.go @@ -0,0 +1,86 @@ +package shell_executor + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" +) + +func TestRunReturnsSanitizedStdout(t *testing.T) { + got, err := Run(context.Background(), t.TempDir(), os.Args[0], "-test.run=TestHelperProcess", "--", "stdout") + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + + want := "line one line two " + if got != want { + t.Fatalf("Run stdout = %q, want %q", got, want) + } +} + +func TestRunReturnsSanitizedStderrOnFailure(t *testing.T) { + _, err := Run(context.Background(), t.TempDir(), os.Args[0], "-test.run=TestHelperProcess", "--", "stderr") + if err == nil { + t.Fatal("Run returned nil error, want failure") + } + + if !strings.Contains(err.Error(), "bad news ") { + t.Fatalf("Run error = %q, want sanitized stderr", err.Error()) + } +} + +func TestRunHonorsContextCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + _, err := Run(ctx, t.TempDir(), os.Args[0], "-test.run=TestHelperProcess", "--", "sleep") + if err == nil { + t.Fatal("Run returned nil error, want context cancellation failure") + } +} + +func TestRunReportsStartError(t *testing.T) { + missingBinary := t.TempDir() + string(os.PathSeparator) + "missing-binary" + + _, err := Run(context.Background(), t.TempDir(), missingBinary) + if err == nil { + t.Fatal("Run returned nil error, want start failure") + } + + if !strings.Contains(err.Error(), missingBinary) { + t.Fatalf("Run error = %q, want missing binary path", err.Error()) + } +} + +func TestHelperProcess(t *testing.T) { + args := os.Args + for i, arg := range args { + if arg != "--" { + continue + } + + runHelperProcess(args[i+1:]) + } +} + +func runHelperProcess(args []string) { + if len(args) == 0 { + os.Exit(0) + } + + switch args[0] { + case "stdout": + fmt.Print("line one\nline two\n") + case "stderr": + _, _ = fmt.Fprint(os.Stderr, "bad\nnews\n") + + os.Exit(42) + case "sleep": + time.Sleep(time.Second) + } + + os.Exit(0) +}