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) +}