├── .dockerignore ├── .editorconfig ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── SUPPORT.md ├── dependabot.yml ├── git-rewrite-author.png ├── labels.yml └── workflows │ ├── build.yml │ ├── codeql.yml │ └── labels.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── main.go ├── docker-bake.hcl ├── go.mod ├── go.sum ├── hack ├── lint.Dockerfile └── vendor.Dockerfile └── internal ├── app ├── app.go ├── config.go ├── list.go └── rewrite.go ├── git ├── config.go ├── filter-branch.go ├── log.go └── repo.go ├── logging └── logger.go ├── model └── cli.go └── utl └── string.go /.dockerignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /dist 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs. 2 | # More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 2 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.go] 18 | indent_style = tab 19 | 20 | [*.json] 21 | insert_final_newline = false 22 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @crazy-max 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: crazy-max 2 | custom: https://www.paypal.me/crazyws 3 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support [![](https://isitmaintained.com/badge/resolution/crazy-max/git-rewrite-author.svg)](https://isitmaintained.com/project/crazy-max/git-rewrite-author) 2 | 3 | ## Reporting an issue 4 | 5 | First search for [existing issues](https://github.com/crazy-max/git-rewrite-author/issues?utf8=%E2%9C%93&q=). If it did not help you, [create a new issue](https://github.com/crazy-max/git-rewrite-author/issues/new) based on this template : 6 | 7 | ``` 8 | ### Behaviour 9 | 10 | #### Steps to reproduce this issue 11 | 12 | 1. 13 | 2. 14 | 3. 15 | 16 | #### Expected behaviour 17 | 18 | > Tell me what should happen 19 | 20 | #### Actual behaviour 21 | 22 | > Tell me what happens instead 23 | 24 | ### Configuration 25 | 26 | **Git version (ex. 2.13.0)** : 27 | 28 | **Operating system (ex. Windows 10 Pro 64 bits)** : 29 | 30 | **git-rewrite-author version (ex. 1.0.0)** : 31 | ``` 32 | 33 | ## Closure policy 34 | 35 | * Issues that don't have the information requested above (when applicable) will be closed immediately and the poster directed to the support guidelines. 36 | * Issues that go a week without a response from original poster are subject to closure at my discretion. 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "08:00" 8 | timezone: "Europe/Paris" 9 | labels: 10 | - ":game_die: dependencies" 11 | - ":robot: bot" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | time: "08:00" 17 | timezone: "Europe/Paris" 18 | labels: 19 | - ":game_die: dependencies" 20 | - ":robot: bot" 21 | -------------------------------------------------------------------------------- /.github/git-rewrite-author.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crazy-max/git-rewrite-author/ba020df2e6f47aca8bcb698aa59f184da46653f8/.github/git-rewrite-author.png -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | ## more info https://github.com/crazy-max/ghaction-github-labeler 2 | - # automerge 3 | name: ":bell: automerge" 4 | color: "8f4fbc" 5 | description: "" 6 | - # bot 7 | name: ":robot: bot" 8 | color: "69cde9" 9 | description: "" 10 | - # bug 11 | name: ":bug: bug" 12 | color: "b60205" 13 | description: "" 14 | - # dependencies 15 | name: ":game_die: dependencies" 16 | color: "0366d6" 17 | description: "" 18 | - # documentation 19 | name: ":memo: documentation" 20 | color: "c5def5" 21 | description: "" 22 | - # duplicate 23 | name: ":busts_in_silhouette: duplicate" 24 | color: "cccccc" 25 | description: "" 26 | - # enhancement 27 | name: ":sparkles: enhancement" 28 | color: "0054ca" 29 | description: "" 30 | - # feature request 31 | name: ":bulb: feature request" 32 | color: "0e8a16" 33 | description: "" 34 | - # feedback 35 | name: ":mega: feedback" 36 | color: "03a9f4" 37 | description: "" 38 | - # future maybe 39 | name: ":rocket: future maybe" 40 | color: "fef2c0" 41 | description: "" 42 | - # good first issue 43 | name: ":hatching_chick: good first issue" 44 | color: "7057ff" 45 | description: "" 46 | - # help wanted 47 | name: ":pray: help wanted" 48 | color: "4caf50" 49 | description: "" 50 | - # invalid 51 | name: ":no_entry_sign: invalid" 52 | color: "e6e6e6" 53 | description: "" 54 | - # investigate 55 | name: ":mag: investigate" 56 | color: "e6625b" 57 | description: "" 58 | - # needs more info 59 | name: ":thinking: needs more info" 60 | color: "795548" 61 | description: "" 62 | - # pinned 63 | name: ":pushpin: pinned" 64 | color: "28008e" 65 | description: "" 66 | - # question 67 | name: ":question: question" 68 | color: "3f51b5" 69 | description: "" 70 | - # sponsor 71 | name: ":sparkling_heart: sponsor" 72 | color: "fedbf0" 73 | description: "" 74 | - # stale 75 | name: ":skull: stale" 76 | color: "237da0" 77 | description: "" 78 | - # upstream 79 | name: ":eyes: upstream" 80 | color: "fbca04" 81 | description: "" 82 | - # wontfix 83 | name: ":coffin: wontfix" 84 | color: "ffffff" 85 | description: "" 86 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | concurrency: 4 | group: build-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'master' 11 | tags: 12 | - '*' 13 | pull_request: 14 | 15 | jobs: 16 | validate: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v3 22 | - 23 | name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v2 25 | - 26 | name: Validate 27 | uses: docker/bake-action@v2 28 | with: 29 | targets: validate 30 | 31 | build: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - 35 | name: Checkout 36 | uses: actions/checkout@v3 37 | with: 38 | fetch-depth: 0 39 | - 40 | name: Set up QEMU 41 | uses: docker/setup-qemu-action@v2 42 | - 43 | name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v2 45 | - 46 | name: Build artifacts 47 | uses: docker/bake-action@v2 48 | with: 49 | targets: artifact-all 50 | - 51 | name: Move artifacts 52 | run: | 53 | mv ./dist/**/* ./dist/ 54 | - 55 | name: Upload artifacts 56 | uses: actions/upload-artifact@v3 57 | with: 58 | name: git-rewrite-author 59 | path: ./dist/* 60 | if-no-files-found: error 61 | - 62 | name: GitHub Release 63 | uses: softprops/action-gh-release@v1 64 | if: startsWith(github.ref, 'refs/tags/') 65 | with: 66 | draft: true 67 | files: | 68 | dist/*.tar.gz 69 | dist/*.zip 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: codeql 2 | 3 | concurrency: 4 | group: codeql-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'master' 11 | tags: 12 | - '*' 13 | pull_request: 14 | branches: 15 | - 'master' 16 | schedule: 17 | - cron: '0 12 * * 6' 18 | 19 | jobs: 20 | codeql: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - 24 | name: Checkout 25 | uses: actions/checkout@v3 26 | with: 27 | fetch-depth: 2 28 | - 29 | name: Checkout HEAD on PR 30 | if: ${{ github.event_name == 'pull_request' }} 31 | run: | 32 | git checkout HEAD^2 33 | - 34 | name: Initialize CodeQL 35 | uses: github/codeql-action/init@v2 36 | with: 37 | languages: go 38 | - 39 | name: Autobuild 40 | uses: github/codeql-action/autobuild@v2 41 | - 42 | name: Perform CodeQL Analysis 43 | uses: github/codeql-action/analyze@v2 44 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: labels 2 | 3 | concurrency: 4 | group: labels-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'master' 11 | paths: 12 | - '.github/labels.yml' 13 | - '.github/workflows/labels.yml' 14 | 15 | jobs: 16 | labeler: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v3 22 | - 23 | name: Run Labeler 24 | uses: crazy-max/ghaction-github-labeler@v4 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /dist 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 10m 3 | 4 | linters: 5 | enable: 6 | - deadcode 7 | - depguard 8 | - gofmt 9 | - goimports 10 | - revive 11 | - govet 12 | - importas 13 | - ineffassign 14 | - misspell 15 | - typecheck 16 | - varcheck 17 | - errname 18 | - makezero 19 | - whitespace 20 | disable-all: true 21 | 22 | linters-settings: 23 | depguard: 24 | list-type: blacklist 25 | include-go-root: true 26 | packages: 27 | # The io/ioutil package has been deprecated. 28 | # https://go.dev/doc/go1.16#ioutil 29 | - io/ioutil 30 | importas: 31 | no-unaliased: true 32 | 33 | issues: 34 | exclude-rules: 35 | - linters: 36 | - revive 37 | text: "stutters" 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.4.0 (2022/07/16) 4 | 5 | * Go 1.18 and add container dev workflow (#55) 6 | * Bump github.com/alecthomas/kong from 0.2.9 to 0.6.1 (#12 #13 #22 #25 #32 #37 #54) 7 | * Bump github.com/rs/zerolog from 1.18.0 to 1.27.0 (#9 #17 #26 #31 #35 #52) 8 | 9 | ## 1.3.0 (2020/05/21) 10 | 11 | * Avoid git filter-branch delay 12 | * Fix filter branch command (#1) 13 | * Switch to kong command-line parser 14 | * Go 1.13 15 | * Update deps 16 | 17 | ## 1.2.0 (2019/10/01) 18 | 19 | * Switch to GitHub Actions 20 | * Review project structure 21 | * Use GOPROXY 22 | * Go 1.12.10 23 | 24 | ## 1.1.2 (2018/09/06) 25 | 26 | * Go 1.11 27 | * Use [go mod](https://golang.org/cmd/go/#hdr-Module_maintenance) instead of `dep` 28 | 29 | ## 1.1.1 (2017/11/14) 30 | 31 | * Typo in rewrite example 32 | 33 | ## 1.1.0 (2017/11/14) 34 | 35 | * Optimization of history rewriting for a list of authors/committers 36 | * Add debug flag 37 | 38 | ## 1.0.0 (2017/11/14) 39 | 40 | * Initial version 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | ARG GO_VERSION="1.18" 4 | ARG GORELEASER_XX_VERSION="1.2.5" 5 | ARG ALPINE_VERSION="3.16" 6 | 7 | FROM --platform=$BUILDPLATFORM crazymax/goreleaser-xx:${GORELEASER_XX_VERSION} AS goreleaser-xx 8 | FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS base 9 | ENV CGO_ENABLED=0 10 | COPY --from=goreleaser-xx / / 11 | RUN apk add --no-cache file git 12 | WORKDIR /src 13 | 14 | FROM base AS vendored 15 | RUN --mount=type=bind,source=.,target=/src,rw \ 16 | --mount=type=cache,target=/go/pkg/mod \ 17 | go mod tidy && go mod download 18 | 19 | FROM vendored AS build 20 | ARG TARGETPLATFORM 21 | RUN --mount=type=bind,target=. \ 22 | --mount=type=cache,target=/root/.cache \ 23 | --mount=target=/go/pkg/mod,type=cache \ 24 | goreleaser-xx --debug \ 25 | --name "git-rewrite-author" \ 26 | --dist "/out" \ 27 | --main="./cmd" \ 28 | --flags="-trimpath" \ 29 | --ldflags="-s -w -X 'main.version={{.Version}}'" \ 30 | --files="CHANGELOG.md" \ 31 | --files="LICENSE" \ 32 | --files="README.md" 33 | 34 | FROM scratch AS artifact 35 | COPY --from=build /out/*.tar.gz / 36 | COPY --from=build /out/*.zip / 37 | 38 | FROM scratch AS binary 39 | COPY --from=build /usr/local/bin/git-rewrite-author* / 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2022 CrazyMax 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | GitHub release 5 | Total downloads 6 | Build Status 7 | Go Report 8 |
Become a sponsor 9 | Donate Paypal 10 |

11 | 12 | ## :warning: Abandoned project 13 | 14 | This project is not maintained anymore and is abandoned. Feel free to fork and 15 | make your own changes if needed. 16 | 17 | ## About 18 | 19 | **git-rewrite-author** is a CLI application written in [Go](https://golang.org/) 20 | to rewrite one or several authors / committers history of a [Git](https://git-scm.com/) 21 | repository with ease. It was inspired by [this post on Github](https://web.archive.org/web/20180604120659/https://help.github.com/articles/changing-author-info/). 22 | 23 | ___ 24 | 25 | * [Requirements](#requirements) 26 | * [Download](#download) 27 | * [Installation](#installation) 28 | * [Usage](#usage) 29 | * [Build](#build) 30 | * [Contributing](#contributing) 31 | * [License](#license) 32 | 33 | ## Requirements 34 | 35 | You must have [Git](https://git-scm.com/) installed on your system and create a 36 | fresh, bare clone of your repository: 37 | 38 | ```console 39 | $ cd /tmp 40 | $ git clone --bare https://github.com/user/repo.git 41 | $ cd /tmp/repo.git 42 | ``` 43 | 44 | ## Download 45 | 46 | You can download the application matching your platform on the 47 | [**releases page**](https://github.com/crazy-max/git-rewrite-author/releases/latest). 48 | 49 | ## Installation 50 | 51 | Place the executable in your Git repository. It is best to place it in your 52 | `PATH` so that you can use it anywhere in your system and also use it with the 53 | Git syntax `git rewrite-author`. 54 | 55 | ## Usage 56 | 57 | ``` 58 | Usage: git-rewrite-author 59 | 60 | Rewrite authors history of a Git repository with ease. More info: 61 | https://github.com/crazy-max/git-rewrite-author 62 | 63 | Flags: 64 | --help Show context-sensitive help. 65 | --version 66 | --repo="." Git repository path. 67 | --log-level="info" Set log level. 68 | --log-caller Add file:line of the caller to log output. 69 | 70 | Commands: 71 | config-get 72 | config-set 73 | list 74 | rewrite 75 | rewrite-list 76 | 77 | Run "git-rewrite-author --help" for more information on a command. 78 | ``` 79 | 80 | You probably want to know the list of authors/committers for a repository 81 | before rewritting history: 82 | 83 | ```console 84 | $ git-rewrite-author list --repo /tmp/repo.git 85 | ohcrap 86 | GitHub 87 | root 88 | ``` 89 | 90 | Then you can rewrite a single author/committer: 91 | 92 | ```console 93 | $ git-rewrite-author rewrite "ohcrap@bad.com" "John Smith " --repo /tmp/repo.git 94 | 95 | Following authors/committers will be rewritten: 96 | - ohcrap@bad.com => John Smith John Smith 122 | - ohcrap@bad.com => Good Sir 123 | 124 | Rewrite 4b03c46d8f085f56014e5bee1e5597de86554139 (31/31) (22 seconds passed, remaining 0 predicted) 125 | Ref 'refs/heads/master' was rewritten 126 | Ref 'refs/remotes/origin/master' was rewritten 127 | Ref 'refs/tags/0.15.1-1' was rewritten 128 | Ref 'refs/tags/0.15.2-2' was rewritten 129 | Ref 'refs/tags/0.15.310-3' was rewritten 130 | Ref 'refs/tags/0.16.9-4' was rewritten 131 | Ref 'refs/tags/0.17.13-5' was rewritten 132 | Ref 'refs/tags/0.17.19-6' was rewritten 133 | Ref 'refs/tags/0.18.14-7' was rewritten 134 | Ref 'refs/tags/0.18.23-8' was rewritten 135 | Ref 'refs/tags/0.18.23-9' was rewritten 136 | Ref 'refs/tags/0.18.36-10' was rewritten 137 | Ref 'refs/tags/0.19.48-11' was rewritten 138 | Ref 'refs/tags/0.19.70-12' was rewritten 139 | ``` 140 | 141 | Here the `authors.json` JSON file looks like this: 142 | 143 | ```json 144 | [ 145 | { 146 | "old": [ "root@localhost", "noreply@github.com" ], 147 | "correct_name": "John Smith", 148 | "correct_mail": "john.smith@domain.com" 149 | }, 150 | { 151 | "old": [ "ohcrap@bad.com" ], 152 | "correct_name": "Good Sir", 153 | "correct_mail": "goodsir@users.noreply.github.com" 154 | } 155 | ] 156 | ``` 157 | 158 | Now confirm everything suits to you: 159 | 160 | ```console 161 | $ git-rewrite-author list --repo /tmp/repo.git 162 | Good Sir 163 | John Smith 164 | ``` 165 | 166 | Review the new Git history for errors and push the corrected history to Git: 167 | 168 | ```console 169 | $ git push --force --all 170 | ``` 171 | 172 | ## Build 173 | 174 | ```shell 175 | git clone https://github.com/crazy-max/git-rewrite-author.git git-rewrite-author 176 | cd git-rewrite-author 177 | 178 | # validate (lint, vendors) 179 | docker buildx bake validate 180 | 181 | # build binary in ./bin 182 | docker buildx bake 183 | 184 | # create artifacts for all supported platforms in ./dist 185 | docker buildx bake artifact-all 186 | ``` 187 | 188 | ## Contributing 189 | 190 | Want to contribute? Awesome! The most basic way to show your support is to star the project, or to raise issues. You 191 | can also support this project by [**becoming a sponsor on GitHub**](https://github.com/sponsors/crazy-max) or by making 192 | a [Paypal donation](https://www.paypal.me/crazyws) to ensure this journey continues indefinitely! 193 | 194 | Thanks again for your support, it is much appreciated! :pray: 195 | 196 | ## License 197 | 198 | MIT. See `LICENSE` for more details.
199 | Icon credit to [ual Pharm](https://www.shareicon.net/author/ual-pharm). 200 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/alecthomas/kong" 10 | "github.com/crazy-max/git-rewrite-author/internal/app" 11 | "github.com/crazy-max/git-rewrite-author/internal/logging" 12 | "github.com/crazy-max/git-rewrite-author/internal/model" 13 | "github.com/rs/zerolog/log" 14 | ) 15 | 16 | var ( 17 | gra *app.GitRewriteAuthor 18 | cli model.Cli 19 | version = "dev" 20 | ) 21 | 22 | func main() { 23 | var err error 24 | 25 | // Parse command line 26 | kctx := kong.Parse(&cli, 27 | kong.Name("git-rewrite-author"), 28 | kong.Description(`Rewrite authors history of a Git repository with ease. More info: https://github.com/crazy-max/git-rewrite-author`), 29 | kong.UsageOnError(), 30 | kong.Vars{ 31 | "version": fmt.Sprintf("%s", version), 32 | }, 33 | kong.ConfigureHelp(kong.HelpOptions{ 34 | Compact: true, 35 | Summary: true, 36 | })) 37 | 38 | // Logger 39 | logging.Configure(cli) 40 | 41 | // Init 42 | if gra, err = app.New(cli); err != nil { 43 | log.Fatal().Err(err).Msg("Cannot initialize") 44 | } 45 | 46 | // Handle os signals 47 | channel := make(chan os.Signal, 1) 48 | signal.Notify(channel, os.Interrupt, syscall.SIGTERM) 49 | go func() { 50 | sig := <-channel 51 | log.Warn().Msgf("Caught signal %v", sig) 52 | os.Exit(0) 53 | }() 54 | 55 | switch kctx.Command() { 56 | case "config-get": 57 | gra.ConfigGet() 58 | case "config-set ": 59 | gra.ConfigSet() 60 | case "list": 61 | gra.List() 62 | case "rewrite ": 63 | gra.RewriteOne() 64 | case "rewrite-list ": 65 | gra.RewriteList() 66 | default: 67 | log.Fatal().Err(err).Msg("Unknown command") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docker-bake.hcl: -------------------------------------------------------------------------------- 1 | variable "GO_VERSION" { 2 | default = "1.18" 3 | } 4 | 5 | target "_common" { 6 | args = { 7 | GO_VERSION = GO_VERSION 8 | BUILDKIT_CONTEXT_KEEP_GIT_DIR = 1 9 | } 10 | } 11 | 12 | group "default" { 13 | targets = ["binary"] 14 | } 15 | 16 | target "binary" { 17 | inherits = ["_common"] 18 | target = "binary" 19 | output = ["./bin"] 20 | } 21 | 22 | target "artifact" { 23 | inherits = ["_common"] 24 | target = "artifact" 25 | output = ["./dist"] 26 | } 27 | 28 | target "artifact-all" { 29 | inherits = ["artifact"] 30 | platforms = [ 31 | "darwin/amd64", 32 | "darwin/arm64", 33 | "freebsd/amd64", 34 | "freebsd/386", 35 | "linux/386", 36 | "linux/amd64", 37 | "linux/arm/v5", 38 | "linux/arm/v6", 39 | "linux/arm/v7", 40 | "linux/arm64", 41 | "windows/386", 42 | "windows/amd64", 43 | "windows/arm64" 44 | ] 45 | } 46 | 47 | target "vendor" { 48 | inherits = ["_common"] 49 | dockerfile = "./hack/vendor.Dockerfile" 50 | target = "update" 51 | output = ["."] 52 | } 53 | 54 | target "gomod-outdated" { 55 | inherits = ["_common"] 56 | dockerfile = "./hack/vendor.Dockerfile" 57 | target = "outdated" 58 | no-cache-filter = ["outdated"] 59 | output = ["type=cacheonly"] 60 | } 61 | 62 | group "validate" { 63 | targets = ["lint", "vendor-validate"] 64 | } 65 | 66 | target "lint" { 67 | inherits = ["_common"] 68 | dockerfile = "./hack/lint.Dockerfile" 69 | target = "lint" 70 | output = ["type=cacheonly"] 71 | } 72 | 73 | target "vendor-validate" { 74 | inherits = ["_common"] 75 | dockerfile = "./hack/vendor.Dockerfile" 76 | target = "validate" 77 | output = ["type=cacheonly"] 78 | } 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/crazy-max/git-rewrite-author 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.6.1 7 | github.com/pkg/errors v0.9.1 8 | github.com/rs/zerolog v1.27.0 9 | ) 10 | 11 | require ( 12 | github.com/mattn/go-colorable v0.1.12 // indirect 13 | github.com/mattn/go-isatty v0.0.14 // indirect 14 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kong v0.6.1 h1:1kNhcFepkR+HmasQpbiKDLylIL8yh5B5y1zPp5bJimA= 2 | github.com/alecthomas/kong v0.6.1/go.mod h1:JfHWDzLmbh/puW6I3V7uWenoh56YNVONW+w8eKeUr9I= 3 | github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48= 4 | github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= 5 | github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 10 | github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= 11 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 12 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 13 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 14 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 15 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 19 | github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs= 20 | github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U= 21 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 22 | github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= 23 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 24 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 25 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= 26 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 29 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 30 | -------------------------------------------------------------------------------- /hack/lint.Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | ARG GO_VERSION="1.18" 4 | ARG GOLANGCI_LINT_VERSION="v1.45" 5 | 6 | FROM golang:${GO_VERSION}-alpine AS base 7 | ENV GOFLAGS="-buildvcs=false" 8 | RUN apk add --no-cache gcc linux-headers musl-dev 9 | WORKDIR /src 10 | 11 | FROM golangci/golangci-lint:${GOLANGCI_LINT_VERSION}-alpine AS golangci-lint 12 | FROM base AS lint 13 | RUN --mount=type=bind,target=. \ 14 | --mount=type=cache,target=/root/.cache \ 15 | --mount=from=golangci-lint,source=/usr/bin/golangci-lint,target=/usr/bin/golangci-lint \ 16 | golangci-lint run ./... 17 | -------------------------------------------------------------------------------- /hack/vendor.Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | ARG GO_VERSION="1.18" 4 | ARG GOMOD_OUTDATED_VERSION="v0.8.0" 5 | 6 | FROM golang:${GO_VERSION}-alpine AS base 7 | RUN apk add --no-cache git linux-headers musl-dev 8 | WORKDIR /src 9 | 10 | FROM base AS vendored 11 | RUN --mount=type=bind,target=.,rw \ 12 | --mount=type=cache,target=/go/pkg/mod <&2 'ERROR: Vendor result differs. Please vendor your package with "docker buildx bake vendor"' 31 | echo "$diff" 32 | exit 1 33 | fi 34 | EOT 35 | 36 | FROM psampaz/go-mod-outdated:${GOMOD_OUTDATED_VERSION} AS go-mod-outdated 37 | FROM base AS outdated 38 | RUN --mount=type=bind,target=. \ 39 | --mount=type=cache,target=/go/pkg/mod \ 40 | --mount=from=go-mod-outdated,source=/home/go-mod-outdated,target=/usr/bin/go-mod-outdated \ 41 | go list -mod=readonly -u -m -json all | go-mod-outdated -update -direct 42 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/crazy-max/git-rewrite-author/internal/git" 5 | "github.com/crazy-max/git-rewrite-author/internal/model" 6 | ) 7 | 8 | // GitRewriteAuthor represents an active git-rewrite-author object 9 | type GitRewriteAuthor struct { 10 | cli model.Cli 11 | repo *git.Repo 12 | } 13 | 14 | // New creates new git-rewrite-author instance 15 | func New(cli model.Cli) (*GitRewriteAuthor, error) { 16 | repo, err := git.Open(cli.Repo) 17 | return &GitRewriteAuthor{ 18 | cli: cli, 19 | repo: repo, 20 | }, err 21 | } 22 | -------------------------------------------------------------------------------- /internal/app/config.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | ) 6 | 7 | // ConfigGet gets current user name and email from Git config 8 | func (gra *GitRewriteAuthor) ConfigGet() { 9 | gra.repo.ReloadConfig() 10 | 11 | if name, found, err := gra.repo.Get("user.name"); found { 12 | log.Info().Msgf("user.name: %s", name) 13 | } else if err != nil { 14 | log.Fatal().Err(err).Msgf("Cannot retrieve user.email in Git config") 15 | } else { 16 | log.Warn().Msg("user.name key not found in Git config") 17 | } 18 | 19 | if email, found, err := gra.repo.Get("user.email"); found { 20 | log.Info().Msgf("user.email: %s", email) 21 | } else if err != nil { 22 | log.Fatal().Err(err).Msgf("Cannot retrieve user.email in Git config") 23 | } else { 24 | log.Warn().Msg("user.email key not found in Git config") 25 | } 26 | } 27 | 28 | // ConfigSet sets user name and email to Git config 29 | func (gra *GitRewriteAuthor) ConfigSet() { 30 | if err := gra.repo.Set("user.name", gra.cli.ConfigSet.Name); err != nil { 31 | log.Fatal().Err(err).Msg("Cannot set user.name") 32 | } 33 | if err := gra.repo.Set("user.email", gra.cli.ConfigSet.Email); err != nil { 34 | log.Fatal().Err(err).Msg("Cannot set user.email") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/app/list.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | ) 6 | 7 | // List displays all authors/committers 8 | func (gra *GitRewriteAuthor) List() { 9 | logs, err := gra.repo.Logs() 10 | if err != nil { 11 | log.Fatal().Err(err).Msg("Cannot fetch git logs") 12 | } 13 | 14 | log.Debug().Msg("Seeking authors...") 15 | for _, author := range logs.GetAuthors() { 16 | log.Info().Msgf("%s <%s>", author.Name, author.Email) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/app/rewrite.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "html/template" 7 | "os" 8 | 9 | "github.com/crazy-max/git-rewrite-author/internal/utl" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | type rewriteAuthor struct { 14 | Old []string `json:"old"` 15 | CorrectName string `json:"correct_name"` 16 | CorrectMail string `json:"correct_mail"` 17 | } 18 | 19 | // Rewrite rewrites an author/committer in Git history 20 | func (gra *GitRewriteAuthor) RewriteOne() { 21 | correctName, correctMail, err := utl.ParseAddress(gra.cli.Rewrite.Correct) 22 | if err != nil { 23 | log.Fatal().Err(err).Msg("Cannot parse new Git name and email") 24 | } 25 | 26 | gra.rewrite([]*rewriteAuthor{ 27 | { 28 | Old: []string{gra.cli.Rewrite.Old}, 29 | CorrectName: correctName, 30 | CorrectMail: correctMail, 31 | }, 32 | }) 33 | } 34 | 35 | // RewriteList rewrites a list of authors/committers in Git history 36 | func (gra *GitRewriteAuthor) RewriteList() { 37 | authorsFile, err := os.ReadFile(gra.cli.RewriteList.File) 38 | if err != nil { 39 | log.Fatal().Err(err).Msg("Cannot read authors JSON file") 40 | } 41 | 42 | var rewriteAuthors []*rewriteAuthor 43 | if err = json.Unmarshal(authorsFile, &rewriteAuthors); err != nil { 44 | log.Fatal().Err(err).Msg("Cannot unmarshal authors JSON") 45 | } 46 | 47 | gra.rewrite(rewriteAuthors) 48 | } 49 | 50 | func (gra *GitRewriteAuthor) rewrite(rewriteAuthors []*rewriteAuthor) { 51 | b, _ := json.MarshalIndent(rewriteAuthors, "", " ") 52 | log.Debug().Msg(string(b)) 53 | 54 | tpl, err := template.New("rewrite").Parse(`{{range $i, $rewriteAuthor := .}} 55 | OLD_EMAILS_{{$i}}=({{range $j, $old := .Old}}{{if $j}} {{end}}"{{$old}}"{{end}}) 56 | CORRECT_NAME_{{$i}}="{{.CorrectName}}" 57 | CORRECT_EMAIL_{{$i}}="{{.CorrectMail}}" 58 | for OLD_EMAIL_{{$i}} in ${OLD_EMAILS_{{$i}}[@]}; do 59 | if [ "$GIT_COMMITTER_EMAIL" = "$OLD_EMAIL_{{$i}}" ]; then 60 | export GIT_COMMITTER_NAME="$CORRECT_NAME_{{$i}}" 61 | export GIT_COMMITTER_EMAIL="$CORRECT_EMAIL_{{$i}}" 62 | fi 63 | if [ "$GIT_AUTHOR_EMAIL" = "$OLD_EMAIL_{{$i}}" ]; then 64 | export GIT_AUTHOR_NAME="$CORRECT_NAME_{{$i}}" 65 | export GIT_AUTHOR_EMAIL="$CORRECT_EMAIL_{{$i}}" 66 | fi 67 | done 68 | {{end}}`) 69 | if err != nil { 70 | log.Fatal().Err(err).Msg("Cannot parse rewrite template") 71 | } 72 | 73 | var rewriteScript bytes.Buffer 74 | if err := tpl.Execute(&rewriteScript, rewriteAuthors); err != nil { 75 | log.Fatal().Err(err).Msg("Cannot execute rewrite template") 76 | } 77 | log.Debug().Msgf("Rewrite script: %s", rewriteScript.String()) 78 | 79 | log.Info().Msg("Following authors/committers will be rewritten") 80 | for _, rewriteAuthor := range rewriteAuthors { 81 | log.Info().Msgf("%s => '%s <%s>'", rewriteAuthor.Old, rewriteAuthor.CorrectName, rewriteAuthor.CorrectMail) 82 | } 83 | 84 | err = gra.repo.FilterBranch("--env-filter", rewriteScript.String(), "--tag-name-filter", "cat", "--", "--branches", "--tags") 85 | if err != nil { 86 | log.Fatal().Err(err).Msg("Cannot rewrite authors") 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /internal/git/config.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | func (r *Repo) readConfig() error { 10 | if r.cfg != nil { 11 | return nil 12 | } 13 | 14 | cmd, stdout, stderr := r.Git("config", "-l", "-z") 15 | if err := cmd.Run(); err != nil { 16 | return errors.Wrap(err, stderr.String()) 17 | } 18 | r.cfg = make(ConfigMap) 19 | 20 | for _, line := range strings.Split(stdout.String(), "\x00") { 21 | parts := strings.SplitN(line, "\n", 2) 22 | if len(parts) != 2 { 23 | continue 24 | } 25 | k := strings.TrimSpace(parts[0]) 26 | v := strings.TrimSpace(parts[1]) 27 | if k == "" { 28 | continue 29 | } 30 | r.cfg[k] = v 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // ReloadConfig will force the config for this git repo to be lazily reloaded. 37 | func (r *Repo) ReloadConfig() { 38 | r.cfg = nil 39 | } 40 | 41 | // Get a specific config value. 42 | func (r *Repo) Get(key string) (val string, found bool, err error) { 43 | err = r.readConfig() 44 | val, found = r.cfg[key] 45 | return 46 | } 47 | 48 | // Set a config variable. 49 | func (r *Repo) Set(key, val string) error { 50 | if err := r.readConfig(); err != nil { 51 | return err 52 | } 53 | 54 | cmd, _, _ := r.Git("config", "--add", key, val) 55 | if err := cmd.Run(); err != nil { 56 | return err 57 | } 58 | 59 | r.cfg[key] = val 60 | return nil 61 | } 62 | 63 | // Find all config variables with a specific prefix. 64 | func (r *Repo) Find(prefix string) (res map[string]string, err error) { 65 | if err := r.readConfig(); err != nil { 66 | return nil, err 67 | } 68 | 69 | res = make(map[string]string) 70 | for k, v := range r.cfg { 71 | if strings.HasPrefix(k, prefix) { 72 | res[k] = v 73 | } 74 | } 75 | 76 | return res, nil 77 | } 78 | -------------------------------------------------------------------------------- /internal/git/filter-branch.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func (r *Repo) FilterBranch(args ...string) (err error) { 11 | var path string 12 | if r.WorkDir == "" { 13 | path = r.GitDir 14 | } else { 15 | path = r.WorkDir 16 | } 17 | 18 | cmdArgs := []string{"filter-branch"} 19 | cmdArgs = append(cmdArgs, args...) 20 | 21 | stdout, stderr := os.Stdout, new(bytes.Buffer) 22 | 23 | cmd := exec.Command(gitCmd, cmdArgs...) 24 | cmd.Stdout, cmd.Stderr = stdout, stderr 25 | cmd.Dir = path 26 | cmd.Env = []string{ 27 | "FILTER_BRANCH_SQUELCH_WARNING=1", 28 | } 29 | 30 | if err := cmd.Run(); err != nil { 31 | return errors.New(stderr.String()) 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/git/log.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | // Log is the main struct that we use to track Git log for a repository. 10 | type Log struct { 11 | Hash string 12 | Author Author 13 | Committer Author 14 | } 15 | 16 | type Author struct { 17 | Name string 18 | Email string 19 | } 20 | 21 | type Authors []Author 22 | 23 | type Logs []Log 24 | 25 | func (r *Repo) Logs() (logs Logs, err error) { 26 | cmd, stdout, stderr := r.Git("--no-pager", "log", "--pretty=%h;%aN,%ae;%cN,%ce") 27 | if err := cmd.Run(); err != nil { 28 | return logs, errors.New(stderr.String()) 29 | } 30 | 31 | for _, line := range strings.Split(stdout.String(), "\n") { 32 | parts := strings.SplitN(line, ";", 3) 33 | if len(parts) != 3 { 34 | continue 35 | } 36 | authorParts := strings.SplitN(strings.TrimSpace(parts[1]), ",", 2) 37 | if len(authorParts) != 2 { 38 | continue 39 | } 40 | committerParts := strings.SplitN(strings.TrimSpace(parts[2]), ",", 2) 41 | if len(committerParts) != 2 { 42 | continue 43 | } 44 | 45 | logs = append(logs, Log{ 46 | Hash: strings.TrimSpace(parts[0]), 47 | Author: Author{ 48 | Name: authorParts[0], 49 | Email: authorParts[1], 50 | }, 51 | Committer: Author{ 52 | Name: committerParts[0], 53 | Email: committerParts[1], 54 | }, 55 | }) 56 | } 57 | 58 | return logs, err 59 | } 60 | 61 | func (l Logs) GetAuthors() (authors Authors) { 62 | for _, log := range l { 63 | authors = appendUniqueAuthor(authors, log.Author) 64 | authors = appendUniqueAuthor(authors, log.Committer) 65 | } 66 | sort.Sort(authors) 67 | return authors 68 | } 69 | 70 | func appendUniqueAuthor(authors Authors, author Author) Authors { 71 | for _, anAuthor := range authors { 72 | if anAuthor.Name == author.Name && anAuthor.Email == author.Email { 73 | return authors 74 | } 75 | } 76 | return append(authors, author) 77 | } 78 | 79 | func (slice Authors) Len() int { 80 | return len(slice) 81 | } 82 | 83 | func (slice Authors) Less(i, j int) bool { 84 | switch strings.Compare(strings.ToUpper(slice[i].Name), strings.ToUpper(slice[j].Name)) { 85 | case -1: 86 | return true 87 | case 0, 1: 88 | return false 89 | default: 90 | return false 91 | } 92 | } 93 | 94 | func (slice Authors) Swap(i, j int) { 95 | slice[i], slice[j] = slice[j], slice[i] 96 | } 97 | -------------------------------------------------------------------------------- /internal/git/repo.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | // ConfigMap maps config keys to their values. 16 | type ConfigMap map[string]string 17 | 18 | // Repo is the main struct that we use to track Git repositories. 19 | type Repo struct { 20 | GitDir string 21 | WorkDir string 22 | cfg ConfigMap 23 | } 24 | 25 | var gitCmd string 26 | 27 | func init() { 28 | var err error 29 | if gitCmd, err = exec.LookPath("git"); err != nil { 30 | log.Fatal().Err(err).Msg("Cannot find git command") 31 | } 32 | } 33 | 34 | func findRepo(path string) (found bool, gitdir, workdir string, err error) { 35 | stat, err := os.Stat(path) 36 | if err != nil { 37 | return 38 | } 39 | if !stat.IsDir() { 40 | err = errors.New(path + " is not a directory") 41 | return 42 | } 43 | 44 | if strings.HasSuffix(path, ".git") { 45 | if stat, err = os.Stat(filepath.Join(path, "config")); err == nil { 46 | found = true 47 | gitdir = path 48 | workdir = "" 49 | return 50 | } 51 | } 52 | 53 | if stat, err = os.Stat(filepath.Join(path, ".git", "config")); err != nil { 54 | return 55 | } 56 | 57 | found = true 58 | gitdir = filepath.Join(path, ".git") 59 | workdir = path 60 | return 61 | } 62 | 63 | // Open the first git repository that "owns" path. 64 | func Open(path string) (repo *Repo, err error) { 65 | if path == "" { 66 | path = "." 67 | } 68 | 69 | path, err = filepath.Abs(path) 70 | basepath := path 71 | if err != nil { 72 | return 73 | } 74 | 75 | for { 76 | found, gitdir, workdir, _ := findRepo(path) 77 | if found { 78 | repo = new(Repo) 79 | repo.GitDir = gitdir 80 | repo.WorkDir = workdir 81 | return 82 | } 83 | parent := filepath.Dir(path) 84 | if parent == path { 85 | break 86 | } 87 | path = parent 88 | } 89 | 90 | return nil, fmt.Errorf("could not find a Git repository in %s or any of its parents", basepath) 91 | } 92 | 93 | // Git is a helper for creating exec.Cmd types and arranging to capture 94 | // the output and erro streams of the command into bytes.Buffers 95 | func Git(cmd string, args ...string) (res *exec.Cmd, stdout, stderr *bytes.Buffer) { 96 | cmdArgs := []string{cmd} 97 | cmdArgs = append(cmdArgs, args...) 98 | res = exec.Command(gitCmd, cmdArgs...) 99 | stdout, stderr = new(bytes.Buffer), new(bytes.Buffer) 100 | res.Stdout, res.Stderr = stdout, stderr 101 | return 102 | } 103 | 104 | // Git is a helper for making sure that the Git command runs in the proper repository. 105 | func (r *Repo) Git(cmd string, args ...string) (res *exec.Cmd, out, err *bytes.Buffer) { 106 | var path string 107 | if r.WorkDir == "" { 108 | path = r.GitDir 109 | } else { 110 | path = r.WorkDir 111 | } 112 | res, out, err = Git(cmd, args...) 113 | res.Dir = path 114 | return 115 | } 116 | 117 | // IsRaw checks to see if this is a raw repository. 118 | func (r *Repo) IsRaw() (res bool) { 119 | return r.WorkDir == "" 120 | } 121 | 122 | // Path returns the best idea of the path to the repository. 123 | // The exact value returned depends on whether this is a 124 | // raw repository or not. 125 | func (r *Repo) Path() (path string) { 126 | if r.IsRaw() { 127 | return r.GitDir 128 | } 129 | return r.WorkDir 130 | } 131 | -------------------------------------------------------------------------------- /internal/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/crazy-max/git-rewrite-author/internal/model" 8 | "github.com/rs/zerolog" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // Configure configures logger 13 | func Configure(cli model.Cli) { 14 | var err error 15 | 16 | ctx := zerolog.New(zerolog.ConsoleWriter{ 17 | Out: os.Stdout, 18 | TimeFormat: time.RFC1123, 19 | }).With().Timestamp() 20 | 21 | if cli.LogCaller { 22 | ctx = ctx.Caller() 23 | } 24 | 25 | log.Logger = ctx.Logger() 26 | 27 | logLevel, err := zerolog.ParseLevel(cli.LogLevel) 28 | if err != nil { 29 | log.Fatal().Err(err).Msgf("Unknown log level") 30 | } else { 31 | zerolog.SetGlobalLevel(logLevel) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/model/cli.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/alecthomas/kong" 4 | 5 | // Cli holds command line args, flags and cmds 6 | type Cli struct { 7 | Version kong.VersionFlag 8 | Repo string `kong:"name='repo',type:'path',default='.',help='Git repository path.'"` 9 | LogLevel string `kong:"name='log-level',default='info',help='Set log level.'"` 10 | LogCaller bool `kong:"name='log-caller',default='false',help='Add file:line of the caller to log output.'"` 11 | ConfigGet struct { 12 | } `kong:"cmd,name='config-get',help:'Get current user name and email from Git config.'"` 13 | ConfigSet struct { 14 | Name string `kong:"arg,required,name='name',help='Git username.'"` 15 | Email string `kong:"arg,required,name='email',help='Git email.'"` 16 | } `kong:"cmd,name='config-set',help:'Set user name and email to Git config.'"` 17 | List struct { 18 | } `kong:"cmd,name='list',help:'Display all authors/committers.'"` 19 | Rewrite struct { 20 | Old string `kong:"arg,required,name='old',help='Current email linked to Git author to rewrite.'"` 21 | Correct string `kong:"arg,required,name='correct',help='New Git name and email to set.'"` 22 | } `kong:"cmd,name='rewrite',help:'Rewrite an author/committer in Git history.'"` 23 | RewriteList struct { 24 | File string `kong:"arg,required,name='file',type:'path',help='Authors JSON file.'"` 25 | } `kong:"cmd,name='rewrite-list',help:'Rewrite a list of authors/committers in Git history.'"` 26 | } 27 | -------------------------------------------------------------------------------- /internal/utl/string.go: -------------------------------------------------------------------------------- 1 | package utl 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | // ParseAddress parses a single RFC 5322 address, e.g. "Barry Gibbs " 9 | func ParseAddress(address string) (name string, email string, err error) { 10 | re := regexp.MustCompile("(.*) <(.*)>") 11 | match := re.FindStringSubmatch(address) 12 | if match == nil || len(match) != 3 { 13 | return "", "", fmt.Errorf("cannot parse %s", address) 14 | } 15 | return match[1], match[2], nil 16 | } 17 | --------------------------------------------------------------------------------