├── .gitignore ├── internal ├── testlib │ ├── test-data-master.zip │ └── data.go ├── git │ ├── random_test.go │ ├── random.go │ ├── authentication_test.go │ ├── authentication.go │ ├── branch_test.go │ ├── remote.go │ ├── remotebranch.go │ ├── helper.go │ ├── file.go │ ├── sort.go │ ├── stash.go │ ├── commit.go │ ├── repository.go │ └── branch.go ├── errors │ ├── errors_test.go │ └── errors.go ├── app │ ├── quick_test.go │ ├── config_test.go │ ├── quick.go │ ├── builder_test.go │ ├── files_test.go │ ├── builder.go │ ├── files.go │ └── config.go ├── command │ ├── merge_test.go │ ├── checkout_test.go │ ├── status_test.go │ ├── checkout.go │ ├── pull_test.go │ ├── diff_test.go │ ├── add_test.go │ ├── fetch_test.go │ ├── commit_test.go │ ├── merge.go │ ├── add.go │ ├── config_test.go │ ├── cmd_test.go │ ├── reset_test.go │ ├── commit.go │ ├── cmd.go │ ├── status.go │ ├── config.go │ ├── reset.go │ ├── diff.go │ ├── pull.go │ └── fetch.go ├── job │ ├── job_test.go │ ├── queue_test.go │ ├── queue.go │ └── job.go ├── gui │ ├── errorview.go │ ├── controlsview.go │ ├── stashview.go │ ├── focusviews.go │ ├── batchbranchesview.go │ ├── overview.go │ ├── extensions.go │ ├── dynamicview.go │ ├── focus.go │ ├── commitview.go │ ├── authenticationview.go │ ├── gui.go │ ├── sideviews.go │ ├── statusview.go │ ├── repositoriesview.go │ └── dynamickeybindings.go └── load │ ├── load_test.go │ └── load.go ├── .goreleaser.yml ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── LICENSE ├── cmd └── gitbatch │ └── main.go ├── README.md └── go.mod /.gitignore: -------------------------------------------------------------------------------- 1 | exec.go.test 2 | build.sh 3 | test.go 4 | .vscode 5 | build/ 6 | coverage.txt 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /internal/testlib/test-data-master.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isacikgoz/gitbatch/HEAD/internal/testlib/test-data-master.zip -------------------------------------------------------------------------------- /internal/git/random_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestRandomString(t *testing.T) { 10 | stringLength := 8 11 | randString := RandomString(stringLength) 12 | require.Equal(t, len(randString), stringLength) 13 | } 14 | -------------------------------------------------------------------------------- /internal/errors/errors_test.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParseGitError(t *testing.T) { 8 | var tests = []struct { 9 | input string 10 | expected error 11 | }{ 12 | {"", ErrUnclassified}, 13 | } 14 | for _, test := range tests { 15 | if output := ParseGitError(test.input, nil); output != test.expected { 16 | t.Errorf("Test Failed. %s expected, output: %s", test.expected.Error(), output.Error()) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /internal/git/random.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | // RandomString generates a random string of n length 9 | func RandomString(n int) string { 10 | var characterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 11 | var r = rand.New(rand.NewSource(time.Now().UnixNano())) 12 | b := make([]rune, n) 13 | for i := range b { 14 | b[i] = characterRunes[r.Intn(len(characterRunes))] 15 | } 16 | return string(b) 17 | } 18 | -------------------------------------------------------------------------------- /internal/app/quick_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestQuick(t *testing.T) { 11 | th := git.InitTestRepositoryFromLocal(t) 12 | defer th.CleanUp(t) 13 | 14 | var tests = []struct { 15 | inp1 []string 16 | inp2 string 17 | }{ 18 | { 19 | []string{th.DirtyRepoPath()}, 20 | "fetch", 21 | }, { 22 | []string{th.DirtyRepoPath()}, 23 | "pull", 24 | }, 25 | } 26 | for _, test := range tests { 27 | err := quick(test.inp1, test.inp2) 28 | require.NoError(t, err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/command/merge_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestMerge(t *testing.T) { 11 | th := git.InitTestRepositoryFromLocal(t) 12 | defer th.CleanUp(t) 13 | 14 | opts := &MergeOptions{ 15 | BranchName: th.Repository.State.Branch.Upstream.Name, 16 | } 17 | var tests = []struct { 18 | inp1 *git.Repository 19 | inp2 *MergeOptions 20 | }{ 21 | {th.Repository, opts}, 22 | } 23 | for _, test := range tests { 24 | err := Merge(test.inp1, test.inp2) 25 | require.NoError(t, err) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | # You may remove this if you don't use go modules. 4 | - go mod download 5 | builds: 6 | - 7 | # Path to main.go file or main package. Default is `.`. 8 | main: ./cmd/gitbatch/main.go 9 | env: 10 | - CGO_ENABLED=0 11 | # GOOS list to build for. Defaults are darwin and linux. 12 | # For more info: https://golang.org/doc/install/source#environment 13 | goos: 14 | - darwin 15 | - linux 16 | - windows 17 | 18 | brews: 19 | - 20 | tap: 21 | owner: isacikgoz 22 | name: homebrew-taps 23 | homepage: "{{ .GitURL }}" 24 | description: Manage your git repositories in one place 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check out code into $GITHUB_WORKSPACE directory 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: 1.18 23 | id: go 24 | 25 | - name: GoReleaser 26 | uses: goreleaser/goreleaser-action@v2 27 | with: 28 | version: latest 29 | args: release --rm-dist 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP }} 32 | -------------------------------------------------------------------------------- /internal/git/authentication_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestAuthProtocol(t *testing.T) { 10 | var tests = []struct { 11 | input *Remote 12 | expected string 13 | }{ 14 | {&Remote{ 15 | URL: []string{"https://gitlab.com/isacikgoz/dirty-repo.git", ""}, 16 | }, "https"}, 17 | {&Remote{ 18 | URL: []string{"http://gitlab.com/isacikgoz/dirty-repo.git", ""}, 19 | }, "http"}, 20 | {&Remote{ 21 | URL: []string{"git@gitlab.com:isacikgoz/dirty-repo.git", ""}, 22 | }, "ssh"}, 23 | } 24 | for _, test := range tests { 25 | protocol, err := AuthProtocol(test.input) 26 | require.NoError(t, err) 27 | require.Equal(t, test.expected, protocol) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/job/job_test.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestStart(t *testing.T) { 11 | th := git.InitTestRepositoryFromLocal(t) 12 | defer th.CleanUp(t) 13 | 14 | mockJob1 := &Job{ 15 | JobType: PullJob, 16 | Repository: th.Repository, 17 | } 18 | mockJob2 := &Job{ 19 | JobType: FetchJob, 20 | Repository: th.Repository, 21 | } 22 | mockJob3 := &Job{ 23 | JobType: MergeJob, 24 | Repository: th.Repository, 25 | } 26 | 27 | var tests = []struct { 28 | input *Job 29 | }{ 30 | {mockJob1}, 31 | {mockJob2}, 32 | {mockJob3}, 33 | } 34 | for _, test := range tests { 35 | err := test.input.start() 36 | require.NoError(t, err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/command/checkout_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCheckout(t *testing.T) { 11 | opts1 := &CheckoutOptions{ 12 | TargetRef: "master", 13 | CreateIfAbsent: false, 14 | CommandMode: ModeLegacy, 15 | } 16 | opts2 := &CheckoutOptions{ 17 | TargetRef: "develop", 18 | CreateIfAbsent: true, 19 | CommandMode: ModeLegacy, 20 | } 21 | 22 | th := git.InitTestRepositoryFromLocal(t) 23 | defer th.CleanUp(t) 24 | 25 | var tests = []struct { 26 | inp1 *git.Repository 27 | inp2 *CheckoutOptions 28 | }{ 29 | {th.Repository, opts1}, 30 | {th.Repository, opts2}, 31 | } 32 | for _, test := range tests { 33 | err := Checkout(test.inp1, test.inp2) 34 | require.NoError(t, err) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/app/config_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestLoadConfiguration(t *testing.T) { 10 | cfg, err := loadConfiguration() 11 | require.NoError(t, err) 12 | require.NotNil(t, cfg) 13 | } 14 | 15 | func TestReadConfiguration(t *testing.T) { 16 | err := readConfiguration() 17 | require.NoError(t, err) 18 | } 19 | 20 | func TestInitializeConfigurationManager(t *testing.T) { 21 | err := initializeConfigurationManager() 22 | require.NoError(t, err) 23 | } 24 | 25 | func TestOsConfigDirectory(t *testing.T) { 26 | var tests = []struct { 27 | input string 28 | expected string 29 | }{ 30 | {"linux", ".config"}, 31 | {"darwin", "Application Support"}, 32 | } 33 | for _, test := range tests { 34 | output := osConfigDirectory(test.input) 35 | require.Contains(t, output, test.expected) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/git/authentication.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | // Credentials holds user credentials to authenticate and authorize while 9 | // communicating with remote if required 10 | type Credentials struct { 11 | // User is the user id for authentication 12 | User string 13 | // Password is the secret information required for authentication 14 | Password string 15 | } 16 | 17 | // Schemes for authentication 18 | const ( 19 | AuthProtocolHTTP = "http" 20 | AuthProtocolHTTPS = "https" 21 | AuthProtocolSSH = "ssh" 22 | ) 23 | 24 | // AuthProtocol returns the type of protocol for given remote's URL 25 | // various auth protocols require different kind of authentication 26 | func AuthProtocol(r *Remote) (p string, err error) { 27 | ur := r.URL[0] 28 | if strings.HasPrefix(ur, "git@") { 29 | return "ssh", nil 30 | } 31 | u, err := url.Parse(ur) 32 | if err != nil { 33 | return p, err 34 | } 35 | return u.Scheme, err 36 | } 37 | -------------------------------------------------------------------------------- /internal/git/branch_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestNextBranch(t *testing.T) { 10 | 11 | } 12 | 13 | func TestPreviousBranch(t *testing.T) { 14 | 15 | } 16 | 17 | func TestRevlistNew(t *testing.T) { 18 | th := InitTestRepositoryFromLocal(t) 19 | defer th.CleanUp(t) 20 | 21 | r := th.Repository 22 | // HEAD..@{u} 23 | headref, err := r.Repo.Head() 24 | if err != nil { 25 | t.Fatalf("Test Failed. error: %s", err.Error()) 26 | } 27 | 28 | head := headref.Hash().String() 29 | 30 | pullables, err := RevList(r, RevListOptions{ 31 | Ref1: head, 32 | Ref2: r.State.Branch.Upstream.Reference.Hash().String(), 33 | }) 34 | require.NoError(t, err) 35 | require.Empty(t, pullables) 36 | 37 | pushables, err := RevList(r, RevListOptions{ 38 | Ref1: r.State.Branch.Upstream.Reference.Hash().String(), 39 | Ref2: head, 40 | }) 41 | require.NoError(t, err) 42 | require.Empty(t, pushables) 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | name: CI 3 | jobs: 4 | test: 5 | env: 6 | GOPATH: ${{ github.workspace }} 7 | 8 | defaults: 9 | run: 10 | working-directory: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Install Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: ${{ matrix.go-version }} 19 | - name: Checkout Code 20 | uses: actions/checkout@v2 21 | with: 22 | path: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} 23 | - name: Execute Tests 24 | run: | 25 | go get -d -t ./... 26 | go test ./... -coverprofile=coverage.txt -covermode=atomic 27 | - name: Upload coverage to Codecov 28 | uses: codecov/codecov-action@v3 29 | with: 30 | directory: . 31 | fail_ci_if_error: true 32 | files: coverage.txt 33 | flags: unittests 34 | verbose: true 35 | -------------------------------------------------------------------------------- /internal/gui/errorview.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jroimartin/gocui" 7 | ) 8 | 9 | var errorReturnView string 10 | 11 | // open an error view to inform user with a message and a useful note 12 | func (gui *Gui) openErrorView(g *gocui.Gui, message, note, returnViewName string) error { 13 | maxX, maxY := g.Size() 14 | errorReturnView = returnViewName 15 | v, err := g.SetView(errorViewFeature.Name, maxX/2-30, maxY/2-3, maxX/2+30, maxY/2+3) 16 | if err != nil { 17 | if err != gocui.ErrUnknownView { 18 | return err 19 | } 20 | v.Title = errorViewFeature.Title 21 | v.Wrap = true 22 | ps := red.Sprint("Note:") + " " + note 23 | fmt.Fprintln(v, message) 24 | fmt.Fprintln(v, ps) 25 | } 26 | return gui.focusToView(errorViewFeature.Name) 27 | } 28 | 29 | // close the opened error view 30 | func (gui *Gui) closeErrorView(g *gocui.Gui, v *gocui.View) error { 31 | 32 | if err := g.DeleteView(v.Name()); err != nil { 33 | return nil 34 | } 35 | return gui.closeViewCleanup(errorReturnView) 36 | } 37 | -------------------------------------------------------------------------------- /internal/command/status_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestStatusWithGit(t *testing.T) { 11 | th := git.InitTestRepositoryFromLocal(t) 12 | defer th.CleanUp(t) 13 | 14 | _, err := testFile(th.RepoPath, "file") 15 | require.NoError(t, err) 16 | 17 | var tests = []struct { 18 | input *git.Repository 19 | }{ 20 | {th.Repository}, 21 | } 22 | for _, test := range tests { 23 | output, err := statusWithGit(test.input) 24 | require.NoError(t, err) 25 | require.NotEmpty(t, output) 26 | } 27 | } 28 | 29 | func TestStatusWithGoGit(t *testing.T) { 30 | th := git.InitTestRepositoryFromLocal(t) 31 | defer th.CleanUp(t) 32 | 33 | _, err := testFile(th.RepoPath, "file") 34 | require.NoError(t, err) 35 | 36 | var tests = []struct { 37 | input *git.Repository 38 | }{ 39 | {th.Repository}, 40 | } 41 | for _, test := range tests { 42 | output, err := statusWithGoGit(test.input) 43 | require.NoError(t, err) 44 | require.NotEmpty(t, output) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ibrahim Serdar Acikgoz 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 | -------------------------------------------------------------------------------- /internal/gui/controlsview.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jroimartin/gocui" 7 | ) 8 | 9 | // open the application controls 10 | // TODO: view size can handled better for such situations like too small 11 | // application area 12 | func (gui *Gui) openCheatSheetView(g *gocui.Gui, _ *gocui.View) error { 13 | maxX, maxY := g.Size() 14 | v, err := g.SetView(cheatSheetViewFeature.Name, maxX/2-25, maxY/2-10, maxX/2+25, maxY/2+10) 15 | if err != nil { 16 | if err != gocui.ErrUnknownView { 17 | return err 18 | } 19 | v.Title = cheatSheetViewFeature.Title 20 | for _, k := range gui.KeyBindings { 21 | if k.View == mainViewFeature.Name || k.View == "" { 22 | binding := " " + cyan.Sprint(k.Display) + ": " + k.Description 23 | fmt.Fprintln(v, binding) 24 | } 25 | } 26 | } 27 | return gui.focusToView(cheatSheetViewFeature.Name) 28 | } 29 | 30 | // close the application controls and do the clean job 31 | func (gui *Gui) closeCheatSheetView(g *gocui.Gui, v *gocui.View) error { 32 | if err := g.DeleteView(v.Name()); err != nil { 33 | return nil 34 | } 35 | return gui.closeViewCleanup(mainViewFeature.Name) 36 | } 37 | -------------------------------------------------------------------------------- /internal/load/load_test.go: -------------------------------------------------------------------------------- 1 | package load 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/isacikgoz/gitbatch/internal/git" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestSyncLoad(t *testing.T) { 12 | th := git.InitTestRepositoryFromLocal(t) 13 | defer th.CleanUp(t) 14 | 15 | var tests = []struct { 16 | input []string 17 | }{ 18 | {[]string{th.BasicRepoPath(), th.DirtyRepoPath()}}, 19 | } 20 | for _, test := range tests { 21 | output, err := SyncLoad(test.input) 22 | require.NoError(t, err) 23 | require.NotEmpty(t, output) 24 | } 25 | } 26 | 27 | func TestAsyncLoad(t *testing.T) { 28 | th := git.InitTestRepositoryFromLocal(t) 29 | defer th.CleanUp(t) 30 | 31 | testChannel := make(chan bool) 32 | testAsyncMockFunc := func(r *git.Repository) { 33 | go func() { 34 | if <-testChannel { 35 | fmt.Println(r.Name) 36 | } 37 | }() 38 | } 39 | 40 | var tests = []struct { 41 | inp1 []string 42 | inp2 AsyncAdd 43 | inp3 chan bool 44 | }{ 45 | {[]string{th.BasicRepoPath(), th.DirtyRepoPath()}, testAsyncMockFunc, testChannel}, 46 | } 47 | for _, test := range tests { 48 | err := AsyncLoad(test.inp1, test.inp2, test.inp3) 49 | require.NoError(t, err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/app/quick.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | 8 | "github.com/isacikgoz/gitbatch/internal/command" 9 | "github.com/isacikgoz/gitbatch/internal/git" 10 | ) 11 | 12 | func quick(directories []string, mode string) error { 13 | var wg sync.WaitGroup 14 | start := time.Now() 15 | for _, dir := range directories { 16 | wg.Add(1) 17 | go func(d string, mode string) { 18 | defer wg.Done() 19 | if err := operate(d, mode); err != nil { 20 | fmt.Printf("could not perform %s on %s: %s", mode, d, err) 21 | } 22 | fmt.Printf("%s: successful\n", d) 23 | }(dir, mode) 24 | } 25 | wg.Wait() 26 | elapsed := time.Since(start) 27 | fmt.Printf("%d repositories finished in: %s\n", len(directories), elapsed) 28 | return nil 29 | } 30 | 31 | func operate(directory, mode string) error { 32 | r, err := git.FastInitializeRepo(directory) 33 | if err != nil { 34 | return err 35 | } 36 | switch mode { 37 | case "fetch": 38 | return command.Fetch(r, &command.FetchOptions{ 39 | RemoteName: "origin", 40 | Progress: true, 41 | }) 42 | case "pull": 43 | return command.Pull(r, &command.PullOptions{ 44 | RemoteName: "origin", 45 | Progress: true, 46 | }) 47 | } 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/git/remote.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "fmt" 4 | 5 | // Remote struct is simply a collection of remote branches and wraps it with the 6 | // name of the remote and fetch/push urls. It also holds the *selected* remote 7 | // branch 8 | type Remote struct { 9 | Name string 10 | URL []string 11 | RefSpecs []string 12 | Branches []*RemoteBranch 13 | } 14 | 15 | // search for remotes in go-git way. It is the short way to get remotes but it 16 | // does not give any insight about remote branches 17 | func (r *Repository) initRemotes() error { 18 | rp := r.Repo 19 | r.Remotes = make([]*Remote, 0) 20 | 21 | rms, err := rp.Remotes() 22 | if err != nil { 23 | return err 24 | } 25 | for _, rm := range rms { 26 | rfs := make([]string, 0) 27 | for _, rf := range rm.Config().Fetch { 28 | rfs = append(rfs, string(rf)) 29 | } 30 | remote := &Remote{ 31 | Name: rm.Config().Name, 32 | URL: rm.Config().URLs, 33 | RefSpecs: rfs, 34 | } 35 | if err := remote.loadRemoteBranches(r); err != nil { 36 | continue 37 | } 38 | r.Remotes = append(r.Remotes, remote) 39 | } 40 | 41 | if len(r.Remotes) <= 0 { 42 | return fmt.Errorf("no remote for repository: %s", r.Name) 43 | } 44 | r.State.Remote = r.Remotes[0] 45 | return err 46 | } 47 | -------------------------------------------------------------------------------- /internal/command/checkout.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | ) 8 | 9 | // CheckoutOptions defines the rules of checkout command 10 | type CheckoutOptions struct { 11 | TargetRef string 12 | CreateIfAbsent bool 13 | CommandMode Mode 14 | } 15 | 16 | // Checkout is a wrapper function for "git checkout" command. 17 | func Checkout(r *git.Repository, o *CheckoutOptions) error { 18 | var branch *git.Branch 19 | for _, b := range r.Branches { 20 | if b.Name == o.TargetRef { 21 | branch = b 22 | break 23 | } 24 | } 25 | msg := "checkout in progress" 26 | if branch != nil { 27 | if err := r.Checkout(branch); err != nil { 28 | r.SetWorkStatus(git.Fail) 29 | msg = err.Error() 30 | } else { 31 | r.SetWorkStatus(git.Success) 32 | msg = "switched to " + o.TargetRef 33 | } 34 | } else if o.CreateIfAbsent { 35 | args := []string{"checkout", "-b", o.TargetRef} 36 | cmd := exec.Command("git", args...) 37 | cmd.Dir = r.AbsPath 38 | _, err := cmd.CombinedOutput() 39 | if err != nil { 40 | r.SetWorkStatus(git.Fail) 41 | msg = err.Error() 42 | } else { 43 | r.SetWorkStatus(git.Success) 44 | msg = "switched to " + o.TargetRef 45 | } 46 | } 47 | r.State.Message = msg 48 | return r.Refresh() 49 | } 50 | -------------------------------------------------------------------------------- /internal/command/pull_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | var ( 11 | testPullopts1 = &PullOptions{ 12 | RemoteName: "origin", 13 | } 14 | 15 | testPullopts2 = &PullOptions{ 16 | RemoteName: "origin", 17 | Force: true, 18 | } 19 | 20 | testPullopts3 = &PullOptions{ 21 | RemoteName: "origin", 22 | Progress: true, 23 | } 24 | ) 25 | 26 | func TestPullWithGit(t *testing.T) { 27 | th := git.InitTestRepositoryFromLocal(t) 28 | defer th.CleanUp(t) 29 | 30 | var tests = []struct { 31 | inp1 *git.Repository 32 | inp2 *PullOptions 33 | }{ 34 | {th.Repository, testPullopts1}, 35 | {th.Repository, testPullopts2}, 36 | } 37 | for _, test := range tests { 38 | err := pullWithGit(test.inp1, test.inp2) 39 | require.NoError(t, err) 40 | } 41 | } 42 | 43 | func TestPullWithGoGit(t *testing.T) { 44 | th := git.InitTestRepositoryFromLocal(t) 45 | defer th.CleanUp(t) 46 | 47 | var tests = []struct { 48 | inp1 *git.Repository 49 | inp2 *PullOptions 50 | }{ 51 | {th.Repository, testPullopts1}, 52 | {th.Repository, testPullopts3}, 53 | } 54 | for _, test := range tests { 55 | err := pullWithGoGit(test.inp1, test.inp2) 56 | require.NoError(t, err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/command/diff_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/isacikgoz/gitbatch/internal/git" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestDiffFile(t *testing.T) { 12 | th := git.InitTestRepositoryFromLocal(t) 13 | defer th.CleanUp(t) 14 | 15 | f := &git.File{ 16 | AbsPath: filepath.Join(th.RepoPath, ".gitignore"), 17 | Name: ".gitignore", 18 | } 19 | 20 | _, err := testFile(th.RepoPath, f.Name) 21 | require.NoError(t, err) 22 | 23 | var tests = []struct { 24 | input *git.File 25 | expected string 26 | }{ 27 | {f, ""}, 28 | } 29 | for _, test := range tests { 30 | output, err := DiffFile(test.input) 31 | require.NoError(t, err) 32 | require.Equal(t, test.expected, output) 33 | } 34 | } 35 | 36 | func TestDiffWithGoGit(t *testing.T) { 37 | th := git.InitTestRepositoryFromLocal(t) 38 | defer th.CleanUp(t) 39 | 40 | headRef, err := th.Repository.Repo.Head() 41 | require.NoError(t, err) 42 | var tests = []struct { 43 | inp1 *git.Repository 44 | inp2 string 45 | expected string 46 | }{ 47 | {th.Repository, headRef.Hash().String(), ""}, 48 | } 49 | for _, test := range tests { 50 | output, err := diffWithGoGit(test.inp1, test.inp2) 51 | require.NoError(t, err) 52 | require.False(t, len(output) == len(test.expected)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cmd/gitbatch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/alecthomas/kingpin" 8 | "github.com/isacikgoz/gitbatch/internal/app" 9 | ) 10 | 11 | func main() { 12 | kingpin.Version("gitbatch version 0.6.1") 13 | 14 | dirs := kingpin.Flag("directory", "Directory(s) to roam for git repositories.").Short('d').Strings() 15 | mode := kingpin.Flag("mode", "Application start mode, more sensible with quick run.").Short('m').String() 16 | recursionDepth := kingpin.Flag("recursive-depth", "Find directories recursively.").Default("0").Short('r').Int() 17 | logLevel := kingpin.Flag("log-level", "Logging level; trace,debug,info,warn,error").Default("error").Short('l').String() 18 | quick := kingpin.Flag("quick", "runs without gui and fetches/pull remote upstream.").Short('q').Bool() 19 | 20 | kingpin.Parse() 21 | 22 | if err := run(*dirs, *logLevel, *recursionDepth, *quick, *mode); err != nil { 23 | fmt.Fprintf(os.Stderr, "application quitted with an unhandled error: %v", err) 24 | os.Exit(1) 25 | } 26 | } 27 | 28 | func run(dirs []string, log string, depth int, quick bool, mode string) error { 29 | app, err := app.New(&app.Config{ 30 | Directories: dirs, 31 | LogLevel: log, 32 | Depth: depth, 33 | QuickMode: quick, 34 | Mode: mode, 35 | }) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | return app.Run() 41 | } 42 | -------------------------------------------------------------------------------- /internal/app/builder_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/isacikgoz/gitbatch/internal/git" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestOverrideConfig(t *testing.T) { 12 | config1 := &Config{ 13 | Directories: []string{}, 14 | LogLevel: "info", 15 | Depth: 1, 16 | QuickMode: false, 17 | Mode: "fetch", 18 | } 19 | config2 := &Config{ 20 | Directories: []string{string(os.PathSeparator) + "tmp"}, 21 | LogLevel: "error", 22 | Depth: 1, 23 | QuickMode: true, 24 | Mode: "pull", 25 | } 26 | 27 | var tests = []struct { 28 | inp1 *Config 29 | inp2 *Config 30 | expected *Config 31 | }{ 32 | {config1, config2, config1}, 33 | } 34 | for _, test := range tests { 35 | output := overrideConfig(test.inp1, test.inp2) 36 | require.Equal(t, test.expected, output) 37 | require.Equal(t, test.inp2.Mode, output.Mode) 38 | } 39 | } 40 | 41 | func TestExecQuickMode(t *testing.T) { 42 | th := git.InitTestRepositoryFromLocal(t) 43 | defer th.CleanUp(t) 44 | 45 | var tests = []struct { 46 | inp1 []string 47 | }{ 48 | {[]string{th.BasicRepoPath()}}, 49 | } 50 | a := App{ 51 | Config: &Config{ 52 | Mode: "fetch", 53 | }, 54 | } 55 | for _, test := range tests { 56 | err := a.execQuickMode(test.inp1) 57 | require.NoError(t, err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/git/remotebranch.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/go-git/go-git/v5/plumbing" 7 | "github.com/go-git/go-git/v5/plumbing/storer" 8 | ) 9 | 10 | // RemoteBranch is the wrapper of go-git's Reference struct. In addition to 11 | // that, it also holds name of the remote branch 12 | type RemoteBranch struct { 13 | Name string 14 | Reference *plumbing.Reference 15 | } 16 | 17 | // search for the remote branches of the remote. It takes the go-git's repo 18 | // pointer in order to get storer struct 19 | func (rm *Remote) loadRemoteBranches(r *Repository) error { 20 | rm.Branches = make([]*RemoteBranch, 0) 21 | bs, err := remoteBranchesIter(r.Repo.Storer) 22 | if err != nil { 23 | return err 24 | } 25 | defer bs.Close() 26 | err = bs.ForEach(func(b *plumbing.Reference) error { 27 | if strings.Split(b.Name().Short(), "/")[0] == rm.Name { 28 | rm.Branches = append(rm.Branches, &RemoteBranch{ 29 | Name: b.Name().Short(), 30 | Reference: b, 31 | }) 32 | } 33 | return nil 34 | }) 35 | return err 36 | } 37 | 38 | // create an iterator for the references. it checks if the reference is a hash 39 | // reference 40 | func remoteBranchesIter(s storer.ReferenceStorer) (storer.ReferenceIter, error) { 41 | refs, err := s.IterReferences() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return storer.NewReferenceFilteredIter(func(ref *plumbing.Reference) bool { 47 | if ref.Type() == plumbing.HashReference { 48 | return ref.Name().IsRemote() 49 | } 50 | return false 51 | }, refs), nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/command/add_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | var ( 11 | testAddopt1 = &AddOptions{} 12 | ) 13 | 14 | func TestAddAll(t *testing.T) { 15 | th := git.InitTestRepositoryFromLocal(t) 16 | defer th.CleanUp(t) 17 | 18 | _, err := testFile(th.RepoPath, "file") 19 | require.NoError(t, err) 20 | 21 | var tests = []struct { 22 | inp1 *git.Repository 23 | inp2 *AddOptions 24 | }{ 25 | {th.Repository, testAddopt1}, 26 | } 27 | for _, test := range tests { 28 | err := AddAll(test.inp1, test.inp2) 29 | require.NoError(t, err) 30 | } 31 | } 32 | 33 | func TestAddWithGit(t *testing.T) { 34 | th := git.InitTestRepositoryFromLocal(t) 35 | defer th.CleanUp(t) 36 | 37 | f, err := testFile(th.RepoPath, "file") 38 | require.NoError(t, err) 39 | 40 | var tests = []struct { 41 | inp1 *git.Repository 42 | inp2 *git.File 43 | inp3 *AddOptions 44 | }{ 45 | {th.Repository, f, testAddopt1}, 46 | } 47 | for _, test := range tests { 48 | err := addWithGit(test.inp1, test.inp2, test.inp3) 49 | require.NoError(t, err) 50 | } 51 | } 52 | 53 | func TestAddWithGoGit(t *testing.T) { 54 | th := git.InitTestRepositoryFromLocal(t) 55 | defer th.CleanUp(t) 56 | 57 | f, err := testFile(th.RepoPath, "file") 58 | require.NoError(t, err) 59 | 60 | var tests = []struct { 61 | inp1 *git.Repository 62 | inp2 *git.File 63 | }{ 64 | {th.Repository, f}, 65 | } 66 | for _, test := range tests { 67 | err := addWithGoGit(test.inp1, test.inp2) 68 | require.NoError(t, err) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/command/fetch_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | var ( 11 | testFetchopts1 = &FetchOptions{ 12 | RemoteName: "origin", 13 | } 14 | 15 | testFetchopts2 = &FetchOptions{ 16 | RemoteName: "origin", 17 | Prune: true, 18 | } 19 | 20 | testFetchopts3 = &FetchOptions{ 21 | RemoteName: "origin", 22 | DryRun: true, 23 | } 24 | 25 | testFetchopts4 = &FetchOptions{ 26 | RemoteName: "origin", 27 | Progress: true, 28 | } 29 | ) 30 | 31 | func TestFetchWithGit(t *testing.T) { 32 | th := git.InitTestRepositoryFromLocal(t) 33 | defer th.CleanUp(t) 34 | 35 | var tests = []struct { 36 | inp1 *git.Repository 37 | inp2 *FetchOptions 38 | }{ 39 | {th.Repository, testFetchopts1}, 40 | {th.Repository, testFetchopts2}, 41 | {th.Repository, testFetchopts3}, 42 | } 43 | for _, test := range tests { 44 | err := fetchWithGit(test.inp1, test.inp2) 45 | require.NoError(t, err) 46 | } 47 | } 48 | 49 | func TestFetchWithGoGit(t *testing.T) { 50 | th := git.InitTestRepositoryFromLocal(t) 51 | defer th.CleanUp(t) 52 | 53 | refspec := "+" + "refs/heads/" + th.Repository.State.Branch.Name + ":" + "/refs/remotes/" + th.Repository.State.Branch.Name 54 | var tests = []struct { 55 | inp1 *git.Repository 56 | inp2 *FetchOptions 57 | inp3 string 58 | }{ 59 | {th.Repository, testFetchopts1, refspec}, 60 | {th.Repository, testFetchopts4, refspec}, 61 | } 62 | for _, test := range tests { 63 | err := fetchWithGoGit(test.inp1, test.inp2, test.inp3) 64 | require.NoError(t, err) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/command/commit_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | giterr "github.com/isacikgoz/gitbatch/internal/errors" 7 | "github.com/isacikgoz/gitbatch/internal/git" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestCommitWithGit(t *testing.T) { 12 | th := git.InitTestRepositoryFromLocal(t) 13 | defer th.CleanUp(t) 14 | 15 | f, err := testFile(th.RepoPath, "file") 16 | require.NoError(t, err) 17 | 18 | err = addWithGit(th.Repository, f, testAddopt1) 19 | require.NoError(t, err) 20 | 21 | testCommitopt1 := &CommitOptions{ 22 | CommitMsg: "test", 23 | User: "foo", 24 | Email: "foo@bar.com", 25 | } 26 | 27 | var tests = []struct { 28 | inp1 *git.Repository 29 | inp2 *CommitOptions 30 | }{ 31 | {th.Repository, testCommitopt1}, 32 | } 33 | for _, test := range tests { 34 | err = commitWithGit(test.inp1, test.inp2) 35 | require.False(t, err != nil && err == giterr.ErrUserEmailNotSet) 36 | } 37 | } 38 | 39 | func TestCommitWithGoGit(t *testing.T) { 40 | th := git.InitTestRepositoryFromLocal(t) 41 | defer th.CleanUp(t) 42 | 43 | f, err := testFile(th.RepoPath, "file") 44 | require.NoError(t, err) 45 | 46 | err = addWithGit(th.Repository, f, testAddopt1) 47 | require.NoError(t, err) 48 | 49 | testCommitopt1 := &CommitOptions{ 50 | CommitMsg: "test", 51 | User: "foo", 52 | Email: "foo@bar.com", 53 | } 54 | 55 | var tests = []struct { 56 | inp1 *git.Repository 57 | inp2 *CommitOptions 58 | }{ 59 | {th.Repository, testCommitopt1}, 60 | } 61 | for _, test := range tests { 62 | err = commitWithGoGit(test.inp1, test.inp2) 63 | require.NoError(t, err) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/testlib/data.go: -------------------------------------------------------------------------------- 1 | package testlib 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "embed" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | //go:embed test-data-master.zip 15 | var zipFile embed.FS 16 | 17 | func ExtractTestRepository(dir string) (string, error) { 18 | data, err := zipFile.ReadFile("test-data-master.zip") 19 | if err != nil { 20 | return "", fmt.Errorf("could not extract test data: %w", err) 21 | } 22 | 23 | r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) 24 | if err != nil { 25 | return "", fmt.Errorf("could not read test data: %w", err) 26 | } 27 | 28 | for _, f := range r.File { 29 | if err := extractFile(dir, f); err != nil { 30 | return "", fmt.Errorf("could not extract test data: %w", err) 31 | } 32 | } 33 | 34 | return filepath.Join(dir, "test-data"), nil 35 | } 36 | 37 | func extractFile(dst string, f *zip.File) error { 38 | rc, err := f.Open() 39 | if err != nil { 40 | return err 41 | } 42 | defer rc.Close() 43 | 44 | path := filepath.Join(dst, f.Name) 45 | 46 | // Check for ZipSlip (Directory traversal) 47 | if !strings.HasPrefix(path, filepath.Clean(dst)+string(os.PathSeparator)) { 48 | return fmt.Errorf("illegal file path: %s", path) 49 | } 50 | 51 | if f.FileInfo().IsDir() { 52 | return os.MkdirAll(path, f.Mode()) 53 | } 54 | 55 | err = os.MkdirAll(filepath.Dir(path), f.Mode()) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 61 | if err != nil { 62 | return err 63 | } 64 | defer file.Close() 65 | 66 | _, err = io.Copy(file, rc) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/command/merge.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "regexp" 5 | 6 | gerr "github.com/isacikgoz/gitbatch/internal/errors" 7 | "github.com/isacikgoz/gitbatch/internal/git" 8 | ) 9 | 10 | // MergeOptions defines the rules of a merge operation 11 | type MergeOptions struct { 12 | // Name of the branch to merge with. 13 | BranchName string 14 | // Be verbose. 15 | Verbose bool 16 | // With true do not show a diffstat at the end of the merge. 17 | NoStat bool 18 | // Mode is the command mode 19 | CommandMode Mode 20 | } 21 | 22 | // Merge incorporates changes from the named commits or branches into the 23 | // current branch 24 | func Merge(r *git.Repository, options *MergeOptions) error { 25 | 26 | args := make([]string, 0) 27 | args = append(args, "merge") 28 | if len(options.BranchName) > 0 { 29 | args = append(args, options.BranchName) 30 | } 31 | if options.Verbose { 32 | args = append(args, "-v") 33 | } 34 | if options.NoStat { 35 | args = append(args, "-n") 36 | } 37 | 38 | ref, _ := r.Repo.Head() 39 | if out, err := Run(r.AbsPath, "git", args); err != nil { 40 | return gerr.ParseGitError(out, err) 41 | } 42 | 43 | newref, _ := r.Repo.Head() 44 | r.SetWorkStatus(git.Success) 45 | msg, err := getMergeMessage(r, ref.Hash().String(), newref.Hash().String()) 46 | if err != nil { 47 | msg = "couldn't get stat" 48 | } 49 | r.State.Message = msg 50 | return r.Refresh() 51 | } 52 | 53 | func getMergeMessage(r *git.Repository, ref1, ref2 string) (string, error) { 54 | var msg string 55 | if ref1 == ref2 { 56 | msg = "already up-to-date" 57 | } else { 58 | out, err := DiffStatRefs(r, ref1, ref2) 59 | if err != nil { 60 | return "", err 61 | } 62 | re := regexp.MustCompile(`\r?\n`) 63 | lines := re.Split(out, -1) 64 | last := lines[len(lines)-1] 65 | if len(last) > 0 { 66 | msg = lines[len(lines)-1][1:] 67 | } 68 | } 69 | return msg, nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/git/helper.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | 10 | "github.com/go-git/go-git/v5" 11 | "github.com/isacikgoz/gitbatch/internal/testlib" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | type TestHelper struct { 16 | Repository *Repository 17 | RepoPath string 18 | } 19 | 20 | func InitTestRepositoryFromLocal(t *testing.T) *TestHelper { 21 | testPathDir, err := ioutil.TempDir("", "gitbatch") 22 | require.NoError(t, err) 23 | 24 | p, err := testlib.ExtractTestRepository(testPathDir) 25 | require.NoError(t, err) 26 | 27 | r, err := InitializeRepo(p) 28 | require.NoError(t, err) 29 | 30 | return &TestHelper{ 31 | Repository: r, 32 | RepoPath: p, 33 | } 34 | } 35 | 36 | func InitTestRepository(t *testing.T) *TestHelper { 37 | testRepoDir, err := ioutil.TempDir("", "test-data") 38 | require.NoError(t, err) 39 | 40 | testRepoURL := "https://gitlab.com/isacikgoz/test-data.git" 41 | _, err = git.PlainClone(testRepoDir, false, &git.CloneOptions{ 42 | URL: testRepoURL, 43 | RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, 44 | }) 45 | 46 | time.Sleep(time.Second) 47 | if err != nil && err != git.NoErrAlreadyUpToDate { 48 | require.FailNow(t, err.Error()) 49 | return nil 50 | } 51 | 52 | r, err := InitializeRepo(testRepoDir) 53 | require.NoError(t, err) 54 | 55 | return &TestHelper{ 56 | Repository: r, 57 | RepoPath: testRepoDir, 58 | } 59 | } 60 | 61 | func (h *TestHelper) CleanUp(t *testing.T) { 62 | err := os.RemoveAll(filepath.Dir(h.RepoPath)) 63 | require.NoError(t, err) 64 | } 65 | 66 | func (h *TestHelper) DirtyRepoPath() string { 67 | return filepath.Join(h.RepoPath, "dirty-repo") 68 | } 69 | 70 | func (h *TestHelper) BasicRepoPath() string { 71 | return filepath.Join(h.RepoPath, "basic-repo") 72 | } 73 | 74 | func (h *TestHelper) NonRepoPath() string { 75 | return filepath.Join(h.RepoPath, "non-repo") 76 | } 77 | -------------------------------------------------------------------------------- /internal/command/add.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | ) 8 | 9 | // AddOptions defines the rules for "git add" command 10 | type AddOptions struct { 11 | // Update 12 | Update bool 13 | // Force 14 | Force bool 15 | // DryRun 16 | DryRun bool 17 | // Mode is the command mode 18 | CommandMode Mode 19 | } 20 | 21 | // Add is a wrapper function for "git add" command 22 | func Add(r *git.Repository, f *git.File, o *AddOptions) error { 23 | mode := o.CommandMode 24 | if o.Update || o.Force || o.DryRun { 25 | mode = ModeLegacy 26 | } 27 | switch mode { 28 | case ModeLegacy: 29 | err := addWithGit(r, f, o) 30 | return err 31 | case ModeNative: 32 | err := addWithGoGit(r, f) 33 | return err 34 | } 35 | return fmt.Errorf("unhandled add operation") 36 | } 37 | 38 | // AddAll function is the wrapper of "git add ." command 39 | func AddAll(r *git.Repository, o *AddOptions) error { 40 | args := make([]string, 0) 41 | args = append(args, "add") 42 | if o.DryRun { 43 | args = append(args, "--dry-run") 44 | } 45 | args = append(args, ".") 46 | _, err := Run(r.AbsPath, "git", args) 47 | if err != nil { 48 | return fmt.Errorf("could not run add function: %v", err) 49 | } 50 | return nil 51 | } 52 | 53 | func addWithGit(r *git.Repository, f *git.File, o *AddOptions) error { 54 | args := make([]string, 0) 55 | args = append(args, "add") 56 | args = append(args, f.Name) 57 | if o.Update { 58 | args = append(args, "--update") 59 | } 60 | if o.Force { 61 | args = append(args, "--force") 62 | } 63 | if o.DryRun { 64 | args = append(args, "--dry-run") 65 | } 66 | _, err := Run(r.AbsPath, "git", args) 67 | if err != nil { 68 | return fmt.Errorf("could not add %s: %v", f.AbsPath, err) 69 | } 70 | return nil 71 | } 72 | 73 | func addWithGoGit(r *git.Repository, f *git.File) error { 74 | w, err := r.Repo.Worktree() 75 | if err != nil { 76 | return err 77 | } 78 | _, err = w.Add(f.Name) 79 | return err 80 | } 81 | -------------------------------------------------------------------------------- /internal/command/config_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestConfigWithGit(t *testing.T) { 11 | th := git.InitTestRepositoryFromLocal(t) 12 | defer th.CleanUp(t) 13 | 14 | testConfigopt := &ConfigOptions{ 15 | Section: "remote.origin", 16 | Option: "url", 17 | Site: ConfigSiteLocal, 18 | } 19 | 20 | var tests = []struct { 21 | inp1 *git.Repository 22 | inp2 *ConfigOptions 23 | expected string 24 | }{ 25 | {th.Repository, testConfigopt, "https://gitlab.com/isacikgoz/test-data.git"}, 26 | } 27 | for _, test := range tests { 28 | output, err := configWithGit(test.inp1, test.inp2) 29 | require.NoError(t, err) 30 | require.Equal(t, test.expected, output) 31 | } 32 | } 33 | 34 | func TestConfigWithGoGit(t *testing.T) { 35 | th := git.InitTestRepositoryFromLocal(t) 36 | defer th.CleanUp(t) 37 | 38 | testConfigopt := &ConfigOptions{ 39 | Section: "core", 40 | Option: "bare", 41 | Site: ConfigSiteLocal, 42 | } 43 | 44 | var tests = []struct { 45 | inp1 *git.Repository 46 | inp2 *ConfigOptions 47 | expected string 48 | }{ 49 | {th.Repository, testConfigopt, "false"}, 50 | } 51 | for _, test := range tests { 52 | output, err := configWithGoGit(test.inp1, test.inp2) 53 | require.NoError(t, err) 54 | require.Equal(t, output, test.expected) 55 | } 56 | } 57 | 58 | func TestAddConfigWithGit(t *testing.T) { 59 | th := git.InitTestRepositoryFromLocal(t) 60 | defer th.CleanUp(t) 61 | 62 | testConfigopt := &ConfigOptions{ 63 | Section: "user", 64 | Option: "name", 65 | Site: ConfigSiteLocal, 66 | } 67 | 68 | var tests = []struct { 69 | inp1 *git.Repository 70 | inp2 *ConfigOptions 71 | inp3 string 72 | }{ 73 | {th.Repository, testConfigopt, "foo"}, 74 | } 75 | for _, test := range tests { 76 | err := addConfigWithGit(test.inp1, test.inp2, test.inp3) 77 | require.NoError(t, err) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /internal/command/cmd_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/isacikgoz/gitbatch/internal/git" 8 | ) 9 | 10 | func TestRun(t *testing.T) { 11 | wd, err := os.Getwd() 12 | if err != nil { 13 | t.Fatalf("Test Failed.") 14 | } 15 | var tests = []struct { 16 | inp1 string 17 | inp2 string 18 | inp3 []string 19 | }{ 20 | {wd, "git", []string{"status"}}, 21 | } 22 | for _, test := range tests { 23 | if output, err := Run(test.inp1, test.inp2, test.inp3); err != nil || len(output) <= 0 { 24 | t.Errorf("Test Failed. {%s, %s, %s} inputted, output: %s", test.inp1, test.inp2, test.inp3, output) 25 | } 26 | } 27 | } 28 | 29 | func TestReturn(t *testing.T) { 30 | wd, err := os.Getwd() 31 | if err != nil { 32 | t.Fatalf("Test Failed.") 33 | } 34 | var tests = []struct { 35 | inp1 string 36 | inp2 string 37 | inp3 []string 38 | expected int 39 | }{ 40 | {wd, "foo", []string{}, -1}, 41 | } 42 | for _, test := range tests { 43 | if output, _ := Return(test.inp1, test.inp2, test.inp3); output != test.expected { 44 | t.Errorf("Test Failed. {%s, %s, %s} inputted, output: %d, expected : %d", test.inp1, test.inp2, test.inp3, output, test.expected) 45 | } 46 | } 47 | } 48 | 49 | func TestTrimTrailingNewline(t *testing.T) { 50 | var tests = []struct { 51 | input string 52 | expected string 53 | }{ 54 | {"foo", "foo"}, 55 | {"foo\n", "foo"}, 56 | {"foo\r", "foo"}, 57 | } 58 | for _, test := range tests { 59 | if output := trimTrailingNewline(test.input); output != test.expected { 60 | t.Errorf("Test Failed. %s inputted, output: %s, expected: %s", test.input, output, test.expected) 61 | } 62 | } 63 | } 64 | 65 | func testFile(testRepoDir, name string) (*git.File, error) { 66 | _, err := os.Create(testRepoDir + string(os.PathSeparator) + name) 67 | if err != nil { 68 | return nil, err 69 | } 70 | f := &git.File{ 71 | Name: name, 72 | AbsPath: testRepoDir + string(os.PathSeparator) + name, 73 | X: git.StatusUntracked, 74 | Y: git.StatusUntracked, 75 | } 76 | return f, nil 77 | } 78 | -------------------------------------------------------------------------------- /internal/command/reset_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | var ( 11 | testResetopt1 = &ResetOptions{} 12 | ) 13 | 14 | func TestResetWithGit(t *testing.T) { 15 | th := git.InitTestRepositoryFromLocal(t) 16 | defer th.CleanUp(t) 17 | 18 | f, err := testFile(th.RepoPath, "file") 19 | require.NoError(t, err) 20 | 21 | err = AddAll(th.Repository, testAddopt1) 22 | require.NoError(t, err) 23 | 24 | var tests = []struct { 25 | inp1 *git.Repository 26 | inp2 *git.File 27 | inp3 *ResetOptions 28 | }{ 29 | {th.Repository, f, testResetopt1}, 30 | } 31 | for _, test := range tests { 32 | err := resetWithGit(test.inp1, test.inp2, test.inp3) 33 | require.NoError(t, err) 34 | } 35 | } 36 | 37 | func TestResetAllWithGit(t *testing.T) { 38 | th := git.InitTestRepositoryFromLocal(t) 39 | defer th.CleanUp(t) 40 | 41 | _, err := testFile(th.RepoPath, "file") 42 | require.NoError(t, err) 43 | 44 | err = AddAll(th.Repository, testAddopt1) 45 | require.NoError(t, err) 46 | 47 | var tests = []struct { 48 | inp1 *git.Repository 49 | inp2 *ResetOptions 50 | }{ 51 | {th.Repository, testResetopt1}, 52 | } 53 | for _, test := range tests { 54 | err := resetAllWithGit(test.inp1, test.inp2) 55 | require.NoError(t, err) 56 | } 57 | } 58 | 59 | func TestResetAllWithGoGit(t *testing.T) { 60 | th := git.InitTestRepositoryFromLocal(t) 61 | defer th.CleanUp(t) 62 | 63 | _, err := testFile(th.RepoPath, "file") 64 | require.NoError(t, err) 65 | 66 | err = AddAll(th.Repository, testAddopt1) 67 | require.NoError(t, err) 68 | 69 | ref, err := th.Repository.Repo.Head() 70 | require.NoError(t, err) 71 | 72 | opt := &ResetOptions{ 73 | Hash: ref.Hash().String(), 74 | ResetType: ResetMixed, 75 | } 76 | var tests = []struct { 77 | inp1 *git.Repository 78 | inp2 *ResetOptions 79 | }{ 80 | {th.Repository, opt}, 81 | } 82 | for _, test := range tests { 83 | err := resetAllWithGoGit(test.inp1, test.inp2) 84 | require.NoError(t, err) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /internal/job/queue_test.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCreateJobQueue(t *testing.T) { 11 | if output := CreateJobQueue(); output == nil { 12 | t.Errorf("Test Failed.") 13 | } 14 | } 15 | 16 | func TestAddJob(t *testing.T) { 17 | th := git.InitTestRepositoryFromLocal(t) 18 | defer th.CleanUp(t) 19 | 20 | q := CreateJobQueue() 21 | var tests = []struct { 22 | input *Job 23 | }{ 24 | {&Job{Repository: th.Repository}}, 25 | } 26 | for _, test := range tests { 27 | err := q.AddJob(test.input) 28 | require.NoError(t, err) 29 | } 30 | } 31 | 32 | func TestRemoveFromQueue(t *testing.T) { 33 | th := git.InitTestRepositoryFromLocal(t) 34 | defer th.CleanUp(t) 35 | 36 | q := CreateJobQueue() 37 | j := &Job{Repository: th.Repository} 38 | err := q.AddJob(j) 39 | require.NoError(t, err) 40 | 41 | var tests = []struct { 42 | input *git.Repository 43 | }{ 44 | {th.Repository}, 45 | } 46 | for _, test := range tests { 47 | err := q.RemoveFromQueue(test.input) 48 | require.NoError(t, err) 49 | } 50 | } 51 | 52 | func TestIsInTheQueue(t *testing.T) { 53 | th := git.InitTestRepositoryFromLocal(t) 54 | defer th.CleanUp(t) 55 | 56 | q := CreateJobQueue() 57 | j := &Job{Repository: th.Repository} 58 | err := q.AddJob(j) 59 | require.NoError(t, err) 60 | 61 | var tests = []struct { 62 | input *git.Repository 63 | }{ 64 | {th.Repository}, 65 | } 66 | for _, test := range tests { 67 | out1, out2 := q.IsInTheQueue(test.input) 68 | require.True(t, out1) 69 | require.Equal(t, j, out2) 70 | } 71 | } 72 | 73 | func TestStartJobsAsync(t *testing.T) { 74 | th := git.InitTestRepositoryFromLocal(t) 75 | defer th.CleanUp(t) 76 | 77 | q := CreateJobQueue() 78 | j := &Job{Repository: th.Repository} 79 | err := q.AddJob(j) 80 | require.NoError(t, err) 81 | 82 | var tests = []struct { 83 | input *Queue 84 | }{ 85 | {q}, 86 | } 87 | for _, test := range tests { 88 | output := test.input.StartJobsAsync() 89 | require.Empty(t, output) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /internal/app/files_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/isacikgoz/gitbatch/internal/git" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestGenerateDirectories(t *testing.T) { 12 | th := git.InitTestRepositoryFromLocal(t) 13 | defer th.CleanUp(t) 14 | 15 | var tests = []struct { 16 | inp1 []string 17 | inp2 int 18 | expected []string 19 | }{ 20 | {[]string{th.RepoPath}, 1, []string{th.BasicRepoPath(), th.DirtyRepoPath()}}, 21 | {[]string{th.RepoPath}, 2, []string{th.BasicRepoPath(), th.DirtyRepoPath()}}, // maybe move one repo to a sub folder 22 | } 23 | for _, test := range tests { 24 | output := generateDirectories(test.inp1, test.inp2) 25 | require.ElementsMatch(t, output, test.expected) 26 | } 27 | } 28 | 29 | func TestWalkRecursive(t *testing.T) { 30 | th := git.InitTestRepositoryFromLocal(t) 31 | defer th.CleanUp(t) 32 | 33 | var tests = []struct { 34 | inp1 []string 35 | inp2 []string 36 | exp1 []string 37 | exp2 []string 38 | }{ 39 | { 40 | []string{th.RepoPath}, 41 | []string{""}, 42 | []string{filepath.Join(th.RepoPath, ".git"), filepath.Join(th.RepoPath, ".gitmodules"), th.NonRepoPath()}, 43 | []string{"", th.BasicRepoPath(), th.DirtyRepoPath()}, 44 | }, 45 | } 46 | for _, test := range tests { 47 | out1, out2 := walkRecursive(test.inp1, test.inp2) 48 | require.ElementsMatch(t, out1, test.exp1) 49 | require.ElementsMatch(t, out2, test.exp2) 50 | } 51 | } 52 | 53 | func TestSeparateDirectories(t *testing.T) { 54 | th := git.InitTestRepositoryFromLocal(t) 55 | defer th.CleanUp(t) 56 | 57 | var tests = []struct { 58 | input string 59 | exp1 []string 60 | exp2 []string 61 | }{ 62 | { 63 | "", 64 | nil, 65 | nil, 66 | }, 67 | { 68 | th.RepoPath, 69 | []string{filepath.Join(th.RepoPath, ".git"), filepath.Join(th.RepoPath, ".gitmodules"), th.NonRepoPath()}, 70 | []string{th.BasicRepoPath(), th.DirtyRepoPath()}, 71 | }, 72 | } 73 | for _, test := range tests { 74 | out1, out2, err := separateDirectories(test.input) 75 | require.NoError(t, err) 76 | require.ElementsMatch(t, out1, test.exp1) 77 | require.ElementsMatch(t, out2, test.exp2) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /internal/command/commit.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | gogit "github.com/go-git/go-git/v5" 8 | "github.com/go-git/go-git/v5/plumbing/object" 9 | giterr "github.com/isacikgoz/gitbatch/internal/errors" 10 | "github.com/isacikgoz/gitbatch/internal/git" 11 | ) 12 | 13 | // CommitOptions defines the rules for commit operation 14 | type CommitOptions struct { 15 | // CommitMsg 16 | CommitMsg string 17 | // User 18 | User string 19 | // Email 20 | Email string 21 | // Mode is the command mode 22 | CommandMode Mode 23 | } 24 | 25 | // Commit defines which commit command to use. 26 | func Commit(r *git.Repository, o *CommitOptions) (err error) { 27 | // here we configure commit operation 28 | 29 | switch o.CommandMode { 30 | case ModeLegacy: 31 | return commitWithGit(r, o) 32 | case ModeNative: 33 | return commitWithGoGit(r, o) 34 | } 35 | return fmt.Errorf("unhandled commit operation") 36 | } 37 | 38 | // commitWithGit is simply a bare git commit -m command which is flexible 39 | func commitWithGit(r *git.Repository, opt *CommitOptions) (err error) { 40 | args := make([]string, 0) 41 | args = append(args, "commit") 42 | args = append(args, "-m") 43 | // parse options to command line arguments 44 | if len(opt.CommitMsg) > 0 { 45 | args = append(args, opt.CommitMsg) 46 | } 47 | if out, err := Run(r.AbsPath, "git", args); err != nil { 48 | _ = r.Refresh() 49 | return giterr.ParseGitError(out, err) 50 | } 51 | // till this step everything should be ok 52 | return r.Refresh() 53 | } 54 | 55 | // commitWithGoGit is the primary commit method 56 | func commitWithGoGit(r *git.Repository, options *CommitOptions) (err error) { 57 | opt := &gogit.CommitOptions{ 58 | Author: &object.Signature{ 59 | Name: options.User, 60 | Email: options.Email, 61 | When: time.Now(), 62 | }, 63 | Committer: &object.Signature{ 64 | Name: options.User, 65 | Email: options.Email, 66 | When: time.Now(), 67 | }, 68 | } 69 | 70 | w, err := r.Repo.Worktree() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | _, err = w.Commit(options.CommitMsg, opt) 76 | if err != nil { 77 | _ = r.Refresh() 78 | return err 79 | } 80 | // till this step everything should be ok 81 | return r.Refresh() 82 | } 83 | -------------------------------------------------------------------------------- /internal/app/builder.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/isacikgoz/gitbatch/internal/gui" 8 | ) 9 | 10 | // The App struct is responsible to hold app-wide related entities. Currently 11 | // it has only the gui.Gui pointer for interface entity. 12 | type App struct { 13 | Config *Config 14 | } 15 | 16 | // Config is an assembler data to initiate a setup 17 | type Config struct { 18 | Directories []string 19 | LogLevel string 20 | Depth int 21 | QuickMode bool 22 | Mode string 23 | } 24 | 25 | // New will handle pre-required operations. It is designed to be a wrapper for 26 | // main method right now. 27 | func New(argConfig *Config) (*App, error) { 28 | // initiate the app and give it initial values 29 | app := &App{} 30 | if len(argConfig.Directories) <= 0 { 31 | d, _ := os.Getwd() 32 | argConfig.Directories = []string{d} 33 | } 34 | presetConfig, err := loadConfiguration() 35 | if err != nil { 36 | return nil, err 37 | } 38 | app.Config = overrideConfig(presetConfig, argConfig) 39 | 40 | return app, nil 41 | } 42 | 43 | // Run starts the application. 44 | func (a *App) Run() error { 45 | dirs := generateDirectories(a.Config.Directories, a.Config.Depth) 46 | if a.Config.QuickMode { 47 | return a.execQuickMode(dirs) 48 | } 49 | // create a gui.Gui struct and run the gui 50 | gui, err := gui.New(a.Config.Mode, dirs) 51 | if err != nil { 52 | return err 53 | } 54 | return gui.Run() 55 | } 56 | 57 | func overrideConfig(appConfig, setupConfig *Config) *Config { 58 | if len(setupConfig.Directories) > 0 { 59 | appConfig.Directories = setupConfig.Directories 60 | } 61 | if len(setupConfig.LogLevel) > 0 { 62 | appConfig.LogLevel = setupConfig.LogLevel 63 | } 64 | if setupConfig.Depth > 0 { 65 | appConfig.Depth = setupConfig.Depth 66 | } 67 | if setupConfig.QuickMode { 68 | appConfig.QuickMode = setupConfig.QuickMode 69 | } 70 | if len(setupConfig.Mode) > 0 { 71 | appConfig.Mode = setupConfig.Mode 72 | } 73 | return appConfig 74 | } 75 | 76 | func (a *App) execQuickMode(directories []string) error { 77 | if a.Config.Mode != "fetch" && a.Config.Mode != "pull" { 78 | return fmt.Errorf("unrecognized quick mode: " + a.Config.Mode) 79 | } 80 | 81 | return quick(directories, a.Config.Mode) 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://img.shields.io/github/actions/workflow/status/isacikgoz/gitbatch/ci.yml) [![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](/LICENSE) [![Go Report Card](https://goreportcard.com/badge/github.com/isacikgoz/gitbatch)](https://goreportcard.com/report/github.com/isacikgoz/gitbatch) 2 | 3 | ## gitbatch 4 | Managing multiple git repositories is easier than ever. I (*was*) often end up working on many directories and manually pulling updates etc. To make this routine faster, I created a simple tool to handle this job. Although the focus is batch jobs, you can still do de facto micro management of your git repositories (e.g *add/reset, stash, commit etc.*) 5 | 6 | Check out the screencast of the app: 7 | [![asciicast](https://asciinema.org/a/lxoZT6Z8fSliIEebWSPVIY8ct.svg)](https://asciinema.org/a/lxoZT6Z8fSliIEebWSPVIY8ct) 8 | 9 | ## Installation 10 | 11 | Install [latest](https://golang.org/dl/) Golang release. 12 | 13 | To install with go, run the following command; 14 | ```bash 15 | go get github.com/isacikgoz/gitbatch/cmd/gitbatch 16 | ``` 17 | or, in Windows 10: 18 | ```bash 19 | go install github.com/isacikgoz/gitbatch/cmd/gitbatch@latest 20 | ``` 21 | 22 | ### MacOS using homebrew 23 | ```bash 24 | brew install gitbatch 25 | ``` 26 | For other options see [installation page](https://github.com/isacikgoz/gitbatch/wiki/Installation) 27 | 28 | ## Use 29 | run the `gitbatch` command from the parent of your git repositories. For start-up options simply `gitbatch --help` 30 | 31 | For more information see the [wiki pages](https://github.com/isacikgoz/gitbatch/wiki) 32 | 33 | ## Further goals 34 | - improve testing 35 | - add push 36 | - full src-d/go-git integration (*having some performance issues in large repos*) 37 | - fetch, config, rev-list, add, reset, commit, status and diff commands are supported but not fully utilized, still using git occasionally 38 | - merge, stash are not supported yet by go-git 39 | 40 | ## Credits 41 | - [go-git](https://github.com/src-d/go-git) for git interface (partially) 42 | - [gocui](https://github.com/jroimartin/gocui) for user interface 43 | - [viper](https://github.com/spf13/viper) for configuration management 44 | - [color](https://github.com/fatih/color) for colored text 45 | - [kingpin](https://github.com/alecthomas/kingpin) for command-line flag&options 46 | 47 | -------------------------------------------------------------------------------- /internal/command/cmd.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "log" 5 | "os/exec" 6 | "strings" 7 | "syscall" 8 | ) 9 | 10 | // Mode indicates that whether command should run native code or use git 11 | // command to operate. 12 | type Mode uint8 13 | 14 | const ( 15 | // ModeLegacy uses traditional git command line tool to operate 16 | ModeLegacy = iota 17 | // ModeNative uses native implementation of given git command 18 | ModeNative 19 | ) 20 | 21 | // Run runs the OS command and return its output. If the output 22 | // returns error it also encapsulates it as a golang.error which is a return code 23 | // of the command except zero 24 | func Run(d string, c string, args []string) (string, error) { 25 | cmd := exec.Command(c, args...) 26 | if d != "" { 27 | cmd.Dir = d 28 | } 29 | output, err := cmd.CombinedOutput() 30 | return trimTrailingNewline(string(output)), err 31 | } 32 | 33 | // Return returns if we supposed to get return value as an int of a command 34 | // this method can be used. It is practical when you use a command and process a 35 | // failover according to a specific return code 36 | func Return(d string, c string, args []string) (int, error) { 37 | cmd := exec.Command(c, args...) 38 | if d != "" { 39 | cmd.Dir = d 40 | } 41 | var err error 42 | // this time the execution is a little different 43 | if err := cmd.Start(); err != nil { 44 | return -1, err 45 | } 46 | if err := cmd.Wait(); err != nil { 47 | if exiterr, ok := err.(*exec.ExitError); ok { 48 | // The program has exited with an exit code != 0 49 | 50 | // This works on both Unix and Windows. Although package 51 | // syscall is generally platform dependent, WaitStatus is 52 | // defined for both Unix and Windows and in both cases has 53 | // an ExitStatus() method with the same signature. 54 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 55 | statusCode := status.ExitStatus() 56 | return statusCode, err 57 | } 58 | } else { 59 | log.Fatalf("cmd.Wait: %v", err) 60 | } 61 | } 62 | return -1, err 63 | } 64 | 65 | // trimTrailingNewline removes the trailing new line form a string. this method 66 | // is used mostly on outputs of a command 67 | func trimTrailingNewline(s string) string { 68 | if strings.HasSuffix(s, "\n") || strings.HasSuffix(s, "\r") { 69 | return s[:len(s)-1] 70 | } 71 | return s 72 | } 73 | -------------------------------------------------------------------------------- /internal/git/file.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "unicode" 5 | ) 6 | 7 | // File represents the status of a file in an index or work tree 8 | type File struct { 9 | Name string 10 | AbsPath string 11 | X FileStatus 12 | Y FileStatus 13 | } 14 | 15 | // FileStatus is the short representation of state of a file 16 | type FileStatus byte 17 | 18 | const ( 19 | // StatusNotupdated says file not updated 20 | StatusNotupdated FileStatus = ' ' 21 | // StatusModified says file is modifed 22 | StatusModified FileStatus = 'M' 23 | // StatusModifiedUntracked says file is modifed and un-tracked 24 | StatusModifiedUntracked FileStatus = 'm' 25 | // StatusAdded says file is added to index 26 | StatusAdded FileStatus = 'A' 27 | // StatusDeleted says file is deleted 28 | StatusDeleted FileStatus = 'D' 29 | // StatusRenamed says file is renamed 30 | StatusRenamed FileStatus = 'R' 31 | // StatusCopied says file is copied 32 | StatusCopied FileStatus = 'C' 33 | // StatusUpdated says file is updated 34 | StatusUpdated FileStatus = 'U' 35 | // StatusUntracked says file is untraced 36 | StatusUntracked FileStatus = '?' 37 | // StatusIgnored says file is ignored 38 | StatusIgnored FileStatus = '!' 39 | ) 40 | 41 | // FilesAlphabetical slice is the re-ordered *File slice that sorted according 42 | // to alphabetical order (A-Z) 43 | type FilesAlphabetical []*File 44 | 45 | // Len is the interface implementation for Alphabetical sorting function 46 | func (s FilesAlphabetical) Len() int { return len(s) } 47 | 48 | // Swap is the interface implementation for Alphabetical sorting function 49 | func (s FilesAlphabetical) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 50 | 51 | // Less is the interface implementation for Alphabetical sorting function 52 | func (s FilesAlphabetical) Less(i, j int) bool { 53 | iRunes := []rune(s[i].Name) 54 | jRunes := []rune(s[j].Name) 55 | 56 | max := len(iRunes) 57 | if max > len(jRunes) { 58 | max = len(jRunes) 59 | } 60 | 61 | for idx := 0; idx < max; idx++ { 62 | ir := iRunes[idx] 63 | jr := jRunes[idx] 64 | 65 | lir := unicode.ToLower(ir) 66 | ljr := unicode.ToLower(jr) 67 | 68 | if lir != ljr { 69 | return lir < ljr 70 | } 71 | 72 | // the lowercase runes are the same, so compare the original 73 | if ir != jr { 74 | return ir < jr 75 | } 76 | } 77 | return false 78 | } 79 | -------------------------------------------------------------------------------- /internal/app/files.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // generateDirectories returns possible git repositories to pipe into git pkg 10 | // load function 11 | func generateDirectories(dirs []string, depth int) []string { 12 | gitDirs := make([]string, 0) 13 | for i := 0; i < depth; i++ { 14 | directories, repositories := walkRecursive(dirs, gitDirs) 15 | dirs = directories 16 | gitDirs = repositories 17 | } 18 | return gitDirs 19 | } 20 | 21 | // returns given values, first search directories and second stands for possible 22 | // git repositories. Call this func from a "for i := 0; i= len(search) { 27 | continue 28 | } 29 | // find possible repositories and remaining ones, b slice is possible ones 30 | a, b, err := separateDirectories(search[i]) 31 | if err != nil { 32 | continue 33 | } 34 | // since we started to search let's get rid of it and remove from search 35 | // array 36 | search[i] = search[len(search)-1] 37 | search = search[:len(search)-1] 38 | // lets append what we have found to continue recursion 39 | search = append(search, a...) 40 | appendant = append(appendant, b...) 41 | } 42 | return search, appendant 43 | } 44 | 45 | // separateDirectories is to find all the files in given path. This method 46 | // does not check if the given file is a valid git repositories 47 | func separateDirectories(directory string) ([]string, []string, error) { 48 | dirs := make([]string, 0) 49 | gitDirs := make([]string, 0) 50 | files, err := ioutil.ReadDir(directory) 51 | // can we read the directory? 52 | if err != nil { 53 | return nil, nil, nil 54 | } 55 | for _, f := range files { 56 | repo := directory + string(os.PathSeparator) + f.Name() 57 | file, err := os.Open(repo) 58 | // if we cannot open it, simply continue to iteration and don't consider 59 | if err != nil { 60 | file.Close() 61 | continue 62 | } 63 | dir, err := filepath.Abs(file.Name()) 64 | if err != nil { 65 | file.Close() 66 | continue 67 | } 68 | // with this approach, we ignore submodule or sub repositories in a git repository 69 | ff, err := os.Open(dir + string(os.PathSeparator) + ".git") 70 | if err != nil { 71 | dirs = append(dirs, dir) 72 | } else { 73 | gitDirs = append(gitDirs, dir) 74 | } 75 | ff.Close() 76 | file.Close() 77 | 78 | } 79 | return dirs, gitDirs, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/load/load.go: -------------------------------------------------------------------------------- 1 | package load 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime" 7 | "sync" 8 | 9 | "github.com/isacikgoz/gitbatch/internal/git" 10 | "golang.org/x/sync/semaphore" 11 | ) 12 | 13 | // AsyncAdd is interface to caller 14 | type AsyncAdd func(r *git.Repository) 15 | 16 | // SyncLoad initializes the go-git's repository objects with given 17 | // slice of paths. since this job is done parallel, the order of the directories 18 | // is not kept 19 | func SyncLoad(directories []string) (entities []*git.Repository, err error) { 20 | entities = make([]*git.Repository, 0) 21 | 22 | var wg sync.WaitGroup 23 | var mu sync.Mutex 24 | 25 | for _, dir := range directories { 26 | // increment wait counter by one because we run a single goroutine 27 | // below 28 | wg.Add(1) 29 | go func(d string) { 30 | // decrement the wait counter by one, we call it in a defer so it's 31 | // called at the end of this goroutine 32 | defer wg.Done() 33 | entity, err := git.InitializeRepo(d) 34 | if err != nil { 35 | return 36 | } 37 | // lock so we don't get a race if multiple go routines try to add 38 | // to the same entities 39 | mu.Lock() 40 | entities = append(entities, entity) 41 | mu.Unlock() 42 | }(dir) 43 | } 44 | // wait until the wait counter is zero, this happens if all goroutines have 45 | // finished 46 | wg.Wait() 47 | if len(entities) == 0 { 48 | return entities, fmt.Errorf("there are no git repositories at given path(s)") 49 | } 50 | return entities, nil 51 | } 52 | 53 | // AsyncLoad asynchronously adds to AsyncAdd function 54 | func AsyncLoad(directories []string, add AsyncAdd, d chan bool) error { 55 | ctx := context.TODO() 56 | 57 | var ( 58 | maxWorkers = runtime.GOMAXPROCS(0) 59 | sem = semaphore.NewWeighted(int64(maxWorkers)) 60 | ) 61 | 62 | var mx sync.Mutex 63 | 64 | // Compute the output using up to maxWorkers goroutines at a time. 65 | for _, dir := range directories { 66 | if err := sem.Acquire(ctx, 1); err != nil { 67 | break 68 | } 69 | 70 | go func(d string) { 71 | 72 | defer sem.Release(1) 73 | entity, err := git.InitializeRepo(d) 74 | if err != nil { 75 | return 76 | } 77 | // lock so we don't get a race if multiple go routines try to add 78 | // to the same entities 79 | mx.Lock() 80 | add(entity) 81 | mx.Unlock() 82 | }(dir) 83 | } 84 | // Acquire all of the tokens to wait for any remaining workers to finish. 85 | if err := sem.Acquire(ctx, int64(maxWorkers)); err != nil { 86 | return err 87 | } 88 | d <- true 89 | sem = nil 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/isacikgoz/gitbatch 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/alecthomas/kingpin v2.2.6+incompatible 7 | github.com/fatih/color v1.13.0 8 | github.com/go-git/go-git/v5 v5.5.2 9 | github.com/jroimartin/gocui v0.5.0 10 | github.com/spf13/viper v1.14.0 11 | github.com/stretchr/testify v1.8.1 12 | golang.org/x/sync v0.1.0 13 | ) 14 | 15 | require ( 16 | github.com/Microsoft/go-winio v0.6.0 // indirect 17 | github.com/ProtonMail/go-crypto v0.0.0-20230113180642-068501e20d67 // indirect 18 | github.com/acomagu/bufpipe v1.0.3 // indirect 19 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 20 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 21 | github.com/cloudflare/circl v1.3.1 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/emirpasic/gods v1.18.1 // indirect 24 | github.com/fsnotify/fsnotify v1.6.0 // indirect 25 | github.com/go-git/gcfg v1.5.0 // indirect 26 | github.com/go-git/go-billy/v5 v5.4.0 // indirect 27 | github.com/hashicorp/hcl v1.0.0 // indirect 28 | github.com/imdario/mergo v0.3.13 // indirect 29 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 30 | github.com/kevinburke/ssh_config v1.2.0 // indirect 31 | github.com/magiconair/properties v1.8.7 // indirect 32 | github.com/mattn/go-colorable v0.1.13 // indirect 33 | github.com/mattn/go-isatty v0.0.17 // indirect 34 | github.com/mattn/go-runewidth v0.0.14 // indirect 35 | github.com/mitchellh/mapstructure v1.5.0 // indirect 36 | github.com/nsf/termbox-go v1.1.1 // indirect 37 | github.com/pelletier/go-toml v1.9.5 // indirect 38 | github.com/pelletier/go-toml/v2 v2.0.6 // indirect 39 | github.com/pjbgf/sha1cd v0.2.3 // indirect 40 | github.com/pmezard/go-difflib v1.0.0 // indirect 41 | github.com/rivo/uniseg v0.4.3 // indirect 42 | github.com/sergi/go-diff v1.3.1 // indirect 43 | github.com/skeema/knownhosts v1.1.0 // indirect 44 | github.com/spf13/afero v1.9.3 // indirect 45 | github.com/spf13/cast v1.5.0 // indirect 46 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 47 | github.com/spf13/pflag v1.0.5 // indirect 48 | github.com/subosito/gotenv v1.4.2 // indirect 49 | github.com/xanzy/ssh-agent v0.3.3 // indirect 50 | golang.org/x/crypto v0.5.0 // indirect 51 | golang.org/x/mod v0.7.0 // indirect 52 | golang.org/x/net v0.5.0 // indirect 53 | golang.org/x/sys v0.4.0 // indirect 54 | golang.org/x/text v0.6.0 // indirect 55 | golang.org/x/tools v0.5.0 // indirect 56 | gopkg.in/ini.v1 v1.67.0 // indirect 57 | gopkg.in/warnings.v0 v0.1.2 // indirect 58 | gopkg.in/yaml.v2 v2.4.0 // indirect 59 | gopkg.in/yaml.v3 v3.0.1 // indirect 60 | ) 61 | -------------------------------------------------------------------------------- /internal/command/status.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/isacikgoz/gitbatch/internal/git" 11 | ) 12 | 13 | func shortStatus(r *git.Repository, option string) string { 14 | args := make([]string, 0) 15 | args = append(args, "status") 16 | args = append(args, option) 17 | args = append(args, "--short") 18 | out, err := Run(r.AbsPath, "git", args) 19 | if err != nil { 20 | return "?" 21 | } 22 | return out 23 | } 24 | 25 | // Status returns the dirty files 26 | func Status(r *git.Repository) ([]*git.File, error) { 27 | // in case we want configure Status command externally 28 | mode := ModeLegacy 29 | 30 | switch mode { 31 | case ModeLegacy: 32 | return statusWithGit(r) 33 | case ModeNative: 34 | return statusWithGoGit(r) 35 | } 36 | return nil, fmt.Errorf("unhandled status operation") 37 | } 38 | 39 | // PlainStatus returns the plain status 40 | func PlainStatus(r *git.Repository) (string, error) { 41 | args := make([]string, 0) 42 | args = append(args, "status") 43 | output, err := Run(r.AbsPath, "git", args) 44 | if err != nil { 45 | return "", err 46 | } 47 | re := regexp.MustCompile(`\n?\r`) 48 | output = re.ReplaceAllString(output, "\n") 49 | return output, err 50 | } 51 | 52 | // LoadFiles function simply commands a git status and collects output in a 53 | // structured way 54 | func statusWithGit(r *git.Repository) ([]*git.File, error) { 55 | files := make([]*git.File, 0) 56 | output := shortStatus(r, "--untracked-files=all") 57 | if len(output) == 0 { 58 | return files, nil 59 | } 60 | fileslist := strings.Split(output, "\n") 61 | for _, file := range fileslist { 62 | x := byte(file[0]) 63 | y := byte(file[1]) 64 | relativePathRegex := regexp.MustCompile(`[(\w|/|.|\-)]+`) 65 | path := relativePathRegex.FindString(file[2:]) 66 | 67 | files = append(files, &git.File{ 68 | Name: path, 69 | AbsPath: r.AbsPath + string(os.PathSeparator) + path, 70 | X: git.FileStatus(x), 71 | Y: git.FileStatus(y), 72 | }) 73 | } 74 | sort.Sort(git.FilesAlphabetical(files)) 75 | return files, nil 76 | } 77 | 78 | func statusWithGoGit(r *git.Repository) ([]*git.File, error) { 79 | files := make([]*git.File, 0) 80 | w, err := r.Repo.Worktree() 81 | if err != nil { 82 | return files, err 83 | } 84 | s, err := w.Status() 85 | if err != nil { 86 | return files, err 87 | } 88 | for k, v := range s { 89 | files = append(files, &git.File{ 90 | Name: k, 91 | AbsPath: r.AbsPath + string(os.PathSeparator) + k, 92 | X: git.FileStatus(v.Staging), 93 | Y: git.FileStatus(v.Worktree), 94 | }) 95 | } 96 | sort.Sort(git.FilesAlphabetical(files)) 97 | return files, nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/command/config.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/isacikgoz/gitbatch/internal/git" 7 | ) 8 | 9 | // ConfigOptions defines the rules for commit operation 10 | type ConfigOptions struct { 11 | // Section 12 | Section string 13 | // Option 14 | Option string 15 | // Site should be Global or Local 16 | Site ConfigSite 17 | // Mode is the command mode 18 | CommandMode Mode 19 | } 20 | 21 | // ConfigSite defines a string type for the site. 22 | type ConfigSite string 23 | 24 | const ( 25 | // ConfigSiteLocal defines a local config. 26 | ConfigSiteLocal ConfigSite = "local" 27 | 28 | // ConfigSiteGlobal defines a global config. 29 | ConfigSiteGlobal ConfigSite = "global" 30 | ) 31 | 32 | // Config adds or reads config of a repository 33 | func Config(r *git.Repository, o *ConfigOptions) (value string, err error) { 34 | // here we configure config operation 35 | 36 | switch o.CommandMode { 37 | case ModeLegacy: 38 | return configWithGit(r, o) 39 | case ModeNative: 40 | return configWithGoGit(r, o) 41 | } 42 | return value, fmt.Errorf("unhandled config operation") 43 | } 44 | 45 | // configWithGit is simply a bare git config --site