diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 30aed4e..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5741115..53a2870 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,20 +10,21 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.16 + go-version: "1.24" - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@v5 with: distribution: goreleaser version: latest args: release --rm-dist env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index a8939fd..e85816c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ - dist/ -/.DS_Store/ \ No newline at end of file +.DS_Store +.idea \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index aae3c12..a95ba91 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,7 +2,6 @@ # Make sure to check the documentation at http://goreleaser.com before: hooks: - # You may remove this if you don't use go modules. - go mod tidy builds: - env: @@ -11,13 +10,16 @@ builds: - linux - windows - darwin + - openbsd + - freebsd archives: - - replacements: - darwin: Darwin - linux: Linux - windows: Windows - 386: i386 - amd64: x86_64 + - name_template: >- + {{- .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end -}} checksum: name_template: 'checksums.txt' snapshot: @@ -28,3 +30,14 @@ changelog: exclude: - '^docs:' - '^test:' +brews: + - repository: + owner: theykk + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" + folder: Formula + description: "Switch between your git profiles easily" + license: "Apache 2.0" + install: | + bin.install "git-switcher" \ No newline at end of file diff --git a/LICENSE b/LICENSE index d645695..e69de29 100644 --- a/LICENSE +++ b/LICENSE @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/README.md b/README.md index 1924fb7..58e6f7b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@


+ + + @@ -27,7 +30,17 @@ brew install theykk/tap/git-switcher With golang ``` -go get github.com/theykk/git-switcher@latest +go install github.com/theykk/git-switcher@latest +``` + +With AUR +``` +yay -S git-switcher +``` +or you can install like this: +``` +git clone https://aur.archlinux.org/git-switcher.git +makepkg -is ``` ## Switch Profile diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..8458173 --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "log" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/manifoldco/promptui" + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/theykk/git-switcher/utils" +) + +// createCmd represents the create command +var createCmd = &cobra.Command{ + Use: "create", + Short: "Creates a new git configuration profile.", + Long: `Creates a new git configuration profile. +You will be prompted to enter a name for the new profile. +A new configuration file will be created in the ~/.config/gitconfigs directory.`, + Run: func(cmd *cobra.Command, args []string) { + confPath, err := homedir.Expand("~/.config/gitconfigs") + if err != nil { + log.Panic(err) + } + + // Ensure confPath directory exists + if _, err := os.Stat(confPath); os.IsNotExist(err) { + if err = os.MkdirAll(confPath, os.ModeDir|0o700); err != nil { + log.Fatalf("Failed to create config directory %s: %v", confPath, err) + } + } + + prom := promptui.Prompt{ + Label: "Profile name", + } + + result, err := prom.Run() + if err != nil { + if err == promptui.ErrInterrupt { + log.Println("Create operation cancelled.") + os.Exit(0) + } + log.Fatalf("Prompt failed %v\n", err) + } + + profilePath := filepath.Join(confPath, result) + + // File is not exist, write to new file + if _, err := os.Stat(profilePath); os.IsNotExist(err) { + utils.Write(profilePath, []byte("[user]\n\tname = "+result+"\n\temail = your_email@example.com")) + color.HiGreen("Profile %q created successfully at %s", result, profilePath) + color.HiYellow("Please edit the file to set your desired git user name and email.") + } else { + color.HiRed("Profile %q already exists at %s", result, profilePath) + } + }, +} + +func init() { + // This function is called when the package is initialized. + // We are adding the createCmd to the rootCmd here. + // This will be done for all command files. + // rootCmd.AddCommand(createCmd) // Will be added in root.go's init +} diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..0624db7 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/manifoldco/promptui" + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/theykk/git-switcher/utils" +) + +// deleteCmd represents the delete command +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Deletes an existing git configuration profile.", + Long: `Deletes an existing git configuration profile. +You will be prompted to select a profile to delete from the available profiles. +The selected configuration file will be removed from the ~/.config/gitconfigs directory. +If the deleted profile is the currently active one, ~/.gitconfig will also be removed.`, + Run: func(cmd *cobra.Command, args []string) { + confPath, err := homedir.Expand("~/.config/gitconfigs") + if err != nil { + log.Panic(err) + } + gitConfigPath, err := homedir.Expand("~/.gitconfig") + if err != nil { + log.Panic(err) + } + + configs := make(map[string]string) // hash: filename + var profiles []string // list of profile filenames + + err = filepath.WalkDir(confPath+"/", func(path string, d fs.DirEntry, e error) error { + if d.IsDir() { + return nil + } + if e != nil { + log.Printf("Warning: error accessing path %s: %v\n", path, e) + return e + } + baseName := filepath.Base(path) + configs[utils.Hash(path)] = baseName + profiles = append(profiles, baseName) + return nil + }) + if err != nil { + log.Fatalf("Error walking directory %s: %v\n", confPath, err) + } + + if len(profiles) == 0 { + fmt.Println("No git configuration profiles found to delete in", confPath) + return + } + + gitConfigHash := "" + if _, errStat := os.Lstat(gitConfigPath); errStat == nil { // Use Lstat to get info about symlink itself + gitConfigHash = utils.Hash(gitConfigPath) + } + + + currentConfigFilename := "none" + currentConfigPos := -1 + if cfName, ok := configs[gitConfigHash]; ok { + currentConfigFilename = cfName + for i, pName := range profiles { + if pName == currentConfigFilename { + currentConfigPos = i + break + } + } + } + + + promptSelect := promptui.Select{ + Label: "Select Git Config profile to delete (Current: " + currentConfigFilename + ")", + Items: profiles, + CursorPos: currentConfigPos, + } + + _, result, err := promptSelect.Run() + if err != nil { + if err == promptui.ErrInterrupt { + log.Println("Delete operation cancelled.") + os.Exit(0) + } + log.Fatalf("Prompt failed %v\n", err) + } + + confirmPrompt := promptui.Prompt{ + Label: fmt.Sprintf("Are you sure you want to delete profile %q? (Y/N)", result), + IsConfirm: true, + } + + _, err = confirmPrompt.Run() + if err != nil { + // This means user entered 'N' or something other than 'y' or 'Y' + // For ErrInterrupt (Ctrl+C), exit. For others, assume 'N'. + if err == promptui.ErrInterrupt { + log.Println("Delete operation cancelled.") + os.Exit(0) + } + color.HiBlue("Profile %q not deleted.", result) + return + } + + profileToDeletePath := filepath.Join(confPath, result) + err = os.Remove(profileToDeletePath) + if err != nil { + log.Fatalf("Failed to delete profile %q: %v", result, err) + } + + // If the deleted profile was the currently active one + if result == currentConfigFilename { + err = os.Remove(gitConfigPath) + if err != nil && !os.IsNotExist(err) { // Don't panic if .gitconfig already gone + log.Fatalf("Failed to remove current .gitconfig symlink: %v", err) + } + color.HiGreen("Profile %q deleted. Current .gitconfig was also removed.", result) + } else { + color.HiGreen("Profile %q deleted.", result) + } + }, +} + +func init() { + // Will be added in root.go + // rootCmd.AddCommand(deleteCmd) +} diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..43a92a0 --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "log" + "os" + "os/exec" + "runtime" + + "github.com/fatih/color" + "github.com/google/shlex" + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" +) + +// editCmd represents the edit command +var editCmd = &cobra.Command{ + Use: "edit", + Short: "Opens the current ~/.gitconfig file in your default editor.", + Long: `Opens the currently active ~/.gitconfig file in your system's +default editor (or $EDITOR environment variable if set). +This allows you to directly modify the active git configuration.`, + Run: func(cmd *cobra.Command, args []string) { + gitConfigPath, err := homedir.Expand("~/.gitconfig") + if err != nil { + log.Panic(err) + } + + // If .gitconfig doesn't exist, inform the user. + // Unlike the root command, 'edit' probably shouldn't create it. + if _, err := os.Stat(gitConfigPath); os.IsNotExist(err) { + color.HiYellow("No active .gitconfig found at %s to edit.", gitConfigPath) + color.HiYellow("Consider switching to or creating a profile first.") + return + } + + editor := os.Getenv("EDITOR") + if editor == "" { + if runtime.GOOS == "windows" { + editor = "notepad" + } else { + editor = "vim" // default to vim on Unix-like systems + } + } + + // Use shlex to properly split editor command into parts (e.g., "code -w") + editorParts, err := shlex.Split(editor) + if err != nil || len(editorParts) == 0 { + log.Fatalf("Failed to parse editor command %q: %v", editor, err) + } + + cmdArgs := append(editorParts[1:], gitConfigPath) + editorCmd := exec.Command(editorParts[0], cmdArgs...) + + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + + color.HiBlue("Opening %s with %s...", gitConfigPath, editor) + if err := editorCmd.Run(); err != nil { + color.HiRed("Editor command %q failed: %s", editor, err) + } + }, +} + +func init() { + // Will be added in root.go + // rootCmd.AddCommand(editCmd) +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..bc6d2b9 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/theykk/git-switcher/utils" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists all available git configuration profiles.", + Long: `Lists all available git configuration profiles stored in ~/.config/gitconfigs. +The currently active profile (if any) will be marked with an asterisk (*) and highlighted.`, + Run: func(cmd *cobra.Command, args []string) { + confPath, err := homedir.Expand("~/.config/gitconfigs") + if err != nil { + log.Panic(err) + } + + gitConfigPath, err := homedir.Expand("~/.gitconfig") + if err != nil { + log.Panic(err) + } + + configs := make(map[string]string) + var profiles []string + + if _, err := os.Stat(confPath); os.IsNotExist(err) { + fmt.Printf("No git configuration directory found at %s.\n", confPath) + fmt.Println("You can create your first profile using 'git-switcher create'.") + return + } + + err = filepath.WalkDir(confPath+"/", func(path string, d fs.DirEntry, e error) error { + if d.IsDir() { + return nil + } + if e != nil { + log.Printf("Warning: error accessing path %s: %v\n", path, e) + return e + } + baseName := filepath.Base(path) + configs[utils.Hash(path)] = baseName + profiles = append(profiles, baseName) + return nil + }) + if err != nil { + log.Printf("Error walking directory %s: %v\n", confPath, err) + return + } + + if len(profiles) == 0 { + fmt.Printf("No git configuration profiles found in %s.\n", confPath) + fmt.Println("You can create your first profile using 'git-switcher create'.") + return + } + + var currentProfile string + gitConfigHash := "" + if _, err := os.Stat(gitConfigPath); err == nil { + gitConfigHash = utils.Hash(gitConfigPath) + if profileName, ok := configs[gitConfigHash]; ok { + currentProfile = profileName + } + } + + fmt.Printf("Available git configuration profiles in %s:\n\n", confPath) + + for _, profile := range profiles { + if profile == currentProfile { + if os.Getenv("NO_COLOR") != "" { + fmt.Printf("* %s (current)\n", profile) + } else { + color.HiGreen("* %s (current)", profile) + } + } else { + fmt.Printf(" %s\n", profile) + } + } + + if currentProfile == "" && len(profiles) > 0 { + if os.Getenv("NO_COLOR") != "" { + fmt.Printf("\nNo active profile detected or current .gitconfig is not managed by git-switcher.\n") + } else { + color.HiYellow("\nNo active profile detected or current .gitconfig is not managed by git-switcher.") + } + fmt.Println("Use 'git-switcher switch ' to activate a profile.") + } + }, +} + +func init() { + // Will be added in root.go +} \ No newline at end of file diff --git a/cmd/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..f407dee --- /dev/null +++ b/cmd/list_test.go @@ -0,0 +1,349 @@ +package cmd + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mitchellh/go-homedir" + "github.com/theykk/git-switcher/utils" +) + +func TestListCmd(t *testing.T) { + tests := []struct { + name string + setupProfiles []string + currentProfile string + expectedInOutput []string + expectError bool + }{ + { + name: "no profiles directory", + setupProfiles: nil, + currentProfile: "", + expectedInOutput: []string{"No git configuration directory found"}, + expectError: false, + }, + { + name: "empty profiles directory", + setupProfiles: []string{}, + currentProfile: "", + expectedInOutput: []string{"No git configuration profiles found"}, + expectError: false, + }, + { + name: "single profile no current", + setupProfiles: []string{"work"}, + currentProfile: "", + expectedInOutput: []string{ + "Available git configuration profiles", + "work", + "Use 'git-switcher switch", + }, + expectError: false, + }, + { + name: "single profile with current", + setupProfiles: []string{"work"}, + currentProfile: "work", + expectedInOutput: []string{ + "Available git configuration profiles", + "work (current)", + }, + expectError: false, + }, + { + name: "multiple profiles with current", + setupProfiles: []string{"work", "personal", "opensource"}, + currentProfile: "personal", + expectedInOutput: []string{ + "Available git configuration profiles", + "personal (current)", + "work", + "opensource", + }, + expectError: false, + }, + { + name: "multiple profiles no current", + setupProfiles: []string{"work", "personal"}, + currentProfile: "", + expectedInOutput: []string{ + "Available git configuration profiles", + "work", + "personal", + "Use 'git-switcher switch", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset homedir cache to ensure test isolation + homedir.Reset() + + // Create temporary directories with unique suffix + tempDir := t.TempDir() + uniqueSuffix := fmt.Sprintf("_%d_%s", time.Now().UnixNano(), tt.name) + tempDir = filepath.Join(tempDir, uniqueSuffix) + err := os.MkdirAll(tempDir, 0755) + if err != nil { + t.Fatalf("Failed to create unique temp directory: %v", err) + } + + // Set environment variables to use temp directory FIRST + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Disable color output for tests + originalNoColor := os.Getenv("NO_COLOR") + os.Setenv("NO_COLOR", "1") + defer os.Setenv("NO_COLOR", originalNoColor) + + confPath := filepath.Join(tempDir, ".config", "gitconfigs") + gitConfigPath := filepath.Join(tempDir, ".gitconfig") + + // Setup profiles if specified + if tt.setupProfiles != nil { + err := os.MkdirAll(confPath, 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + for _, profile := range tt.setupProfiles { + profilePath := filepath.Join(confPath, profile) + content := "[user]\n\tname = " + profile + "\n\temail = " + profile + "@example.com" + utils.Write(profilePath, []byte(content)) + } + } + + // Setup current profile if specified + if tt.currentProfile != "" { + currentProfilePath := filepath.Join(confPath, tt.currentProfile) + if _, err := os.Stat(currentProfilePath); err == nil { + // Remove existing .gitconfig if present + os.Remove(gitConfigPath) + // Create symlink to current profile + err := os.Symlink(currentProfilePath, gitConfigPath) + if err != nil { + t.Fatalf("Failed to create symlink: %v", err) + } + } + } + + // Capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Capture stderr as well for log messages + oldStderr := os.Stderr + r2, w2, _ := os.Pipe() + os.Stderr = w2 + + // Execute the command + listCmd.Run(listCmd, []string{}) + + // Restore stdout/stderr + w.Close() + os.Stdout = oldStdout + w2.Close() + os.Stderr = oldStderr + + // Read captured output + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + var bufErr bytes.Buffer + io.Copy(&bufErr, r2) + errOutput := bufErr.String() + + // Combine both outputs for checking + combinedOutput := output + errOutput + + // Check expected strings in output + for _, expected := range tt.expectedInOutput { + if !strings.Contains(combinedOutput, expected) { + t.Errorf("Expected output to contain %q, but got:\n%s", expected, combinedOutput) + } + } + + // Additional checks for specific test cases + if tt.currentProfile != "" { + // Should contain the current profile marker + expectedCurrentMarker := tt.currentProfile + " (current)" + if !strings.Contains(combinedOutput, expectedCurrentMarker) { + t.Errorf("Expected output to contain current profile marker %q, but got:\n%s", expectedCurrentMarker, combinedOutput) + } + } + + // For multiple profiles, check that all are listed + if len(tt.setupProfiles) > 1 { + for _, profile := range tt.setupProfiles { + if profile != tt.currentProfile { + // Non-current profiles should appear without marker + if !strings.Contains(combinedOutput, " "+profile) { + t.Errorf("Expected output to contain profile %q without marker, but got:\n%s", profile, combinedOutput) + } + } + } + } + }) + } +} + +func TestListCmdEdgeCases(t *testing.T) { + t.Run("corrupted gitconfig", func(t *testing.T) { + // Reset homedir cache to ensure test isolation + homedir.Reset() + + // Create temporary directories with unique suffix + tempDir := t.TempDir() + uniqueSuffix := fmt.Sprintf("_%d_corrupted", time.Now().UnixNano()) + tempDir = filepath.Join(tempDir, uniqueSuffix) + err := os.MkdirAll(tempDir, 0755) + if err != nil { + t.Fatalf("Failed to create unique temp directory: %v", err) + } + + // Set environment variables to use temp directory FIRST + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Disable color output for tests + originalNoColor := os.Getenv("NO_COLOR") + os.Setenv("NO_COLOR", "1") + defer os.Setenv("NO_COLOR", originalNoColor) + + confPath := filepath.Join(tempDir, ".config", "gitconfigs") + gitConfigPath := filepath.Join(tempDir, ".gitconfig") + + // Setup a profile + err = os.MkdirAll(confPath, 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + profilePath := filepath.Join(confPath, "work") + utils.Write(profilePath, []byte("[user]\n\tname = work\n\temail = work@example.com")) + + // Create a corrupted .gitconfig (not a symlink to any profile) + utils.Write(gitConfigPath, []byte("[user]\n\tname = other\n\temail = other@example.com")) + + // Capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + oldStderr := os.Stderr + r2, w2, _ := os.Pipe() + os.Stderr = w2 + + // Execute the command + listCmd.Run(listCmd, []string{}) + + // Restore stdout/stderr + w.Close() + os.Stdout = oldStdout + w2.Close() + os.Stderr = oldStderr + + // Read captured output + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + var bufErr bytes.Buffer + io.Copy(&bufErr, r2) + errOutput := bufErr.String() + + combinedOutput := output + errOutput + + // Should show profiles but indicate no active profile + expectedStrings := []string{ + "Available git configuration profiles", + "work", + "Use 'git-switcher switch", + } + + for _, expected := range expectedStrings { + if !strings.Contains(combinedOutput, expected) { + t.Errorf("Expected output to contain %q, but got:\n%s", expected, combinedOutput) + } + } + }) + + t.Run("broken symlink gitconfig", func(t *testing.T) { + // Reset homedir cache to ensure test isolation + homedir.Reset() + + // Create temporary directories with unique suffix + tempDir := t.TempDir() + uniqueSuffix := fmt.Sprintf("_%d_broken_symlink", time.Now().UnixNano()) + tempDir = filepath.Join(tempDir, uniqueSuffix) + err := os.MkdirAll(tempDir, 0755) + if err != nil { + t.Fatalf("Failed to create unique temp directory: %v", err) + } + + // Set environment variables to use temp directory FIRST + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Disable color output for tests + originalNoColor := os.Getenv("NO_COLOR") + os.Setenv("NO_COLOR", "1") + defer os.Setenv("NO_COLOR", originalNoColor) + + confPath := filepath.Join(tempDir, ".config", "gitconfigs") + gitConfigPath := filepath.Join(tempDir, ".gitconfig") + + // Setup a profile + err = os.MkdirAll(confPath, 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + profilePath := filepath.Join(confPath, "work") + utils.Write(profilePath, []byte("[user]\n\tname = work\n\temail = work@example.com")) + + // Create a broken symlink + brokenTarget := filepath.Join(confPath, "nonexistent") + err = os.Symlink(brokenTarget, gitConfigPath) + if err != nil { + t.Fatalf("Failed to create broken symlink: %v", err) + } + + // Capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Execute the command (this should not panic) + listCmd.Run(listCmd, []string{}) + + // Restore stdout + w.Close() + os.Stdout = oldStdout + + // Read captured output + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + // Should still show profiles even with broken symlink + if !strings.Contains(output, "work") { + t.Errorf("Expected output to contain profile 'work', but got:\n%s", output) + } + }) +} \ No newline at end of file diff --git a/cmd/rename.go b/cmd/rename.go new file mode 100644 index 0000000..94f6cda --- /dev/null +++ b/cmd/rename.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/manifoldco/promptui" + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" +) + +// renameCmd represents the rename command +var renameCmd = &cobra.Command{ + Use: "rename", + Short: "Renames an existing git configuration profile.", + Long: `Renames an existing git configuration profile. +You will be prompted to select the profile to rename and then to enter the new name. +The configuration file in ~/.config/gitconfigs will be renamed. +If the renamed profile is the currently active one, the ~/.gitconfig symlink will be updated.`, + Run: func(cmd *cobra.Command, args []string) { + confPath, err := homedir.Expand("~/.config/gitconfigs") + if err != nil { + log.Panic(err) + } + gitConfigPath, err := homedir.Expand("~/.gitconfig") + if err != nil { + log.Panic(err) + } + + var profiles []string + err = filepath.WalkDir(confPath+"/", func(path string, d fs.DirEntry, e error) error { + if d.IsDir() { + return nil + } + if e != nil { + log.Printf("Warning: error accessing path %s: %v\n", path, e) + return e + } + profiles = append(profiles, filepath.Base(path)) + return nil + }) + if err != nil { + log.Fatalf("Error listing profiles in %s: %v\n", confPath, err) + } + + if len(profiles) == 0 { + fmt.Println("No git configuration profiles found to rename in", confPath) + return + } + + promptOldName := promptui.Select{ + Label: "Select profile to rename", + Items: profiles, + } + _, oldNameStr, err := promptOldName.Run() // Correctly capture the string result + if err != nil { + if err == promptui.ErrInterrupt { log.Println("Rename operation cancelled."); os.Exit(0)} + log.Fatalf("Prompt failed %v\n", err) + } + + promptNewName := promptui.Prompt{ + Label: fmt.Sprintf("Enter new name for profile %q", oldNameStr), + Validate: func(input string) error { + if input == "" { + return fmt.Errorf("profile name cannot be empty") + } + // Check if new name already exists + for _, p := range profiles { + if p == input && p != oldNameStr { + return fmt.Errorf("profile %q already exists", input) + } + } + return nil + }, + } + newName, err := promptNewName.Run() + if err != nil { + if err == promptui.ErrInterrupt { log.Println("Rename operation cancelled."); os.Exit(0)} + log.Fatalf("Prompt failed %v\n", err) + } + + if oldNameStr == newName { + color.HiYellow("New name is the same as the old name. No changes made.") + return + } + + oldProfilePath := filepath.Join(confPath, oldNameStr) + newProfilePath := filepath.Join(confPath, newName) + + err = os.Rename(oldProfilePath, newProfilePath) + if err != nil { + log.Fatalf("Failed to rename profile %q to %q: %v", oldNameStr, newName, err) + } + color.HiGreen("Profile %q renamed to %q.", oldNameStr, newName) + + // Check if the renamed profile was the active one + // Readlink correctly resolves the symlink path + currentTarget, errReadLink := os.Readlink(gitConfigPath) + if errReadLink == nil { // If .gitconfig is a symlink + if currentTarget == oldProfilePath { // And it pointed to the old profile path + errRemove := os.Remove(gitConfigPath) + if errRemove != nil && !os.IsNotExist(errRemove) { + log.Printf("Warning: failed to remove old symlink %s: %v", gitConfigPath, errRemove) + } + errSymlink := os.Symlink(newProfilePath, gitConfigPath) + if errSymlink != nil { + log.Fatalf("Failed to update symlink for active profile to %q: %v", newName, errSymlink) + } + color.HiBlue("Active profile symlink updated to %q.", newName) + } + } else if !os.IsNotExist(errReadLink) { + // If os.Readlink failed for a reason other than .gitconfig not existing (e.g. it's not a symlink) + log.Printf("Warning: Could not determine if active profile needed symlink update: %v", errReadLink) + } + }, +} + +func init() { + // Will be added in root.go + // rootCmd.AddCommand(renameCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..d2c64d4 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/manifoldco/promptui" + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/theykk/git-switcher/utils" +) + +var rootCmd = &cobra.Command{ + Use: "git-switcher", + Short: "A tool to easily switch between different git configurations.", + Long: `git-switcher allows you to manage multiple git configurations +and switch between them with a simple interactive prompt or direct commands. + +This is useful when you work on different projects that require +different user names or email addresses for git commits.`, + Run: func(cmd *cobra.Command, args []string) { + confPath, err := homedir.Expand("~/.config/gitconfigs") + if err != nil { + log.Panic(err) + } + + log.SetFlags(log.Lshortfile) + configs := make(map[string]string) + + if _, err := os.Stat(confPath); os.IsNotExist(err) { + err = os.MkdirAll(confPath, os.ModeDir|0o700) + if err != nil { + log.Println(err) + } + } + + err = filepath.WalkDir(confPath+"/", func(path string, d fs.DirEntry, e error) error { + if d.IsDir() { + return nil + } + if e != nil { + log.Printf("Warning: error accessing path %s: %v\n", path, e) + return e + } + configs[utils.Hash(path)] = filepath.Base(path) + return nil + }) + if err != nil { + log.Printf("Error walking directory %s: %v\n", confPath, err) + } + + gitConfig, err := homedir.Expand("~/.gitconfig") + if err != nil { + log.Panic(err) + } + + if _, err := os.Stat(gitConfig); os.IsNotExist(err) { + utils.Write(gitConfig, []byte("[user]\n\tname = username")) + } + gitConfigHash := utils.Hash(gitConfig) + + if _, ok := configs[gitConfigHash]; !ok { + oldConfigsPath := filepath.Join(confPath, "old-configs") + lstatInfo, lstatErr := os.Lstat(gitConfig) + isSymlink := false + if lstatErr == nil && (lstatInfo.Mode()&os.ModeSymlink != 0) { + isSymlink = true + } + + if !isSymlink { + if _, statErr := os.Stat(oldConfigsPath); os.IsNotExist(statErr) { + errLink := os.Link(gitConfig, oldConfigsPath) + if errLink != nil { + log.Printf("Warning: Failed to link current .gitconfig to %s: %v\n", oldConfigsPath, errLink) + } else { + log.Printf("Info: Current .gitconfig backed up to %s\n", oldConfigsPath) + configs[utils.Hash(oldConfigsPath)] = filepath.Base(oldConfigsPath) + } + } else if statErr == nil { + log.Printf("Info: %s already exists. Current .gitconfig not linked as old-configs.\n", oldConfigsPath) + } + } else { + log.Printf("Info: Current .gitconfig at %s is a symlink, not backing up to old-configs.\n", gitConfig) + } + } + + configs = make(map[string]string) + err = filepath.WalkDir(confPath+"/", func(path string, d fs.DirEntry, e error) error { + if d.IsDir() { return nil } + if e != nil { log.Printf("Warning: error accessing path %s: %v\n", path, e); return e } + configs[utils.Hash(path)] = filepath.Base(path) + return nil + }) + if err != nil { log.Printf("Error re-walking directory %s: %v\n", confPath, err) } + + var profiles []string + var currentConfigPos int = -1 + i := 0 + currentConfigFilename := "unknown (current .gitconfig may not be a saved profile)" + + _, gitConfigHashOk := configs[gitConfigHash] + if gitConfigHashOk { + currentConfigFilename = configs[gitConfigHash] + } + + for hash, val := range configs { + if hash == gitConfigHash { + currentConfigPos = i + } + profiles = append(profiles, val) + i++ + } + + if len(profiles) == 0 { + fmt.Printf("No git configuration profiles found in %s.\n", confPath) + fmt.Println("You can create one using 'git-switcher create'.") + return + } + + selectLabel := "Select Git Config" + if currentConfigPos != -1 { + selectLabel += " (Current: " + currentConfigFilename + ")" + } else { + selectLabel += " (Current: " + currentConfigFilename + " - not in saved profiles)" + } + + prompt := promptui.Select{ + Label: selectLabel, + Items: profiles, + CursorPos: currentConfigPos, + HideSelected: true, + } + + _, result, err := prompt.Run() + if err != nil { + if err == promptui.ErrInterrupt { + fmt.Println("Operation cancelled.") + os.Exit(0) + } + fmt.Printf("Prompt failed %v\n", err) + os.Exit(1) + } + newConfig := result + + err = os.Remove(gitConfig) + if err != nil && !os.IsNotExist(err) { + log.Panic(err) + } + + err = os.Symlink(filepath.Join(confPath, newConfig), gitConfig) + if err != nil { + log.Panic(err) + } + color.HiBlue("Switched to profile %q", newConfig) + }, +} + +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.AddCommand(createCmd) + rootCmd.AddCommand(deleteCmd) + rootCmd.AddCommand(renameCmd) + rootCmd.AddCommand(editCmd) + rootCmd.AddCommand(switchCmd) + rootCmd.AddCommand(listCmd) +} + + diff --git a/cmd/switch.go b/cmd/switch.go new file mode 100644 index 0000000..79de3d2 --- /dev/null +++ b/cmd/switch.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" +) + +// switchCmd represents the switch command +var switchCmd = &cobra.Command{ + Use: "switch [profile_name]", + Short: "Switches the active git configuration to the specified profile.", + Long: `Switches the active git configuration to the specified profile. +The command takes exactly one argument: the name of the profile to switch to. +This profile must exist in the ~/.config/gitconfigs directory. +The ~/.gitconfig file will be updated to be a symlink to the selected profile.`, + Args: cobra.ExactArgs(1), // Ensures exactly one argument (profile_name) is passed + Run: func(cmd *cobra.Command, args []string) { + profileName := args[0] + + confPath, err := homedir.Expand("~/.config/gitconfigs") + if err != nil { + log.Panic(err) + } + gitConfigPath, err := homedir.Expand("~/.gitconfig") + if err != nil { + log.Panic(err) + } + + targetProfilePath := filepath.Join(confPath, profileName) + + // Check if the target profile exists + if _, err := os.Stat(targetProfilePath); os.IsNotExist(err) { + color.HiRed("Error: Profile %q does not exist at %s.", profileName, targetProfilePath) + // List available profiles for user convenience + var availableProfiles []string + errList := filepath.WalkDir(confPath, func(path string, d os.DirEntry, e error) error { + if !d.IsDir() && path != targetProfilePath { // Exclude the non-existent one + availableProfiles = append(availableProfiles, filepath.Base(path)) + } + return nil + }) + if errList == nil && len(availableProfiles) > 0 { + fmt.Println("Available profiles:") + for _, p := range availableProfiles { + fmt.Printf(" - %s\n", p) + } + } else if errList != nil { + log.Printf("Could not list available profiles: %v", errList) + } + os.Exit(1) + } + + // Remove current .gitconfig symlink or file if it exists + err = os.Remove(gitConfigPath) + if err != nil && !os.IsNotExist(err) { // Ignore if it doesn't exist, panic for other errors + log.Fatalf("Failed to remove existing .gitconfig at %s: %v", gitConfigPath, err) + } + + // Create the new symlink + err = os.Symlink(targetProfilePath, gitConfigPath) + if err != nil { + log.Fatalf("Failed to create symlink from %s to %s: %v", targetProfilePath, gitConfigPath, err) + } + + color.HiBlue("Switched to profile %q. ~/.gitconfig now points to %s.", profileName, targetProfilePath) + }, +} + +func init() { + // Will be added in root.go + // rootCmd.AddCommand(switchCmd) +} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..f9e2405 --- /dev/null +++ b/config.toml @@ -0,0 +1,30 @@ +ignoreGeneratedHeader = false +severity = "warning" +confidence = 0.8 +errorCode = 0 +warningCode = 0 + +[rule.blank-imports] +[rule.context-as-argument] +[rule.context-keys-type] +[rule.dot-imports] +[rule.error-return] +[rule.error-strings] +[rule.error-naming] +[rule.exported] +[rule.if-return] +[rule.increment-decrement] +[rule.var-naming] +[rule.var-declaration] +[rule.package-comments] +[rule.range] +[rule.receiver-naming] +[rule.time-naming] +[rule.unexported-return] +[rule.indent-error-flow] +[rule.errorf] +[rule.empty-block] +[rule.superfluous-else] +[rule.unused-parameter] +[rule.unreachable-code] +[rule.redefines-builtin-id] diff --git a/go.mod b/go.mod index a9d6bc7..13dd527 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,22 @@ module github.com/theykk/git-switcher -go 1.16 +go 1.23.0 + +toolchain go1.24.3 require ( - github.com/fatih/color v1.12.0 - github.com/manifoldco/promptui v0.8.0 + github.com/fatih/color v1.18.0 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/manifoldco/promptui v0.9.0 github.com/mitchellh/go-homedir v1.1.0 - golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect + github.com/spf13/cobra v1.9.1 +) + +require ( + github.com/chzyer/readline v1.5.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index a16d22a..52d6cba 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,45 @@ github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc= -github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= -github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= -github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= -github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= -github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= -github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index ce201fc..a6feac8 100644 --- a/main.go +++ b/main.go @@ -1,231 +1,9 @@ -// Copyright 2021 Kaan Karakaya -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - package main import ( - "crypto/md5" - "encoding/hex" - "fmt" - "github.com/fatih/color" - "github.com/manifoldco/promptui" - "github.com/mitchellh/go-homedir" - "io" - "io/fs" - "log" - "os" - "path/filepath" + "github.com/theykk/git-switcher/cmd" ) func main() { - confPath, err := homedir.Expand("~/.config/gitconfigs") - if err != nil { - log.Panic(err) - } - - log.SetFlags(log.Lshortfile) - // hash: filename - configs := make(map[string]string) - - if _, err := os.Stat(confPath); os.IsNotExist(err) { - // Give permission for only current user - err = os.Mkdir(confPath, os.ModeDir|0700) - if err != nil { - log.Println(err) - } - } - - // List ~/.gitconfigs folder - err = filepath.WalkDir(confPath+"/", func(path string, d fs.DirEntry, e error) error { - if d.IsDir() { - return nil - } - - configs[hash(path)] = filepath.Base(path) - - return nil - }) - if err != nil { - log.Println(err) - } - - // Check current gitconfig is exist in configs - gitConfig, _ := homedir.Expand("~/.gitconfig") - - // If gitconfig file is not exist create empty file - if _, err := os.Stat(gitConfig); os.IsNotExist(err) { - write(gitConfig, []byte("")) - } - gitConfigHash := hash(gitConfig) - if _, ok := configs[gitConfigHash]; !ok { - err := os.Link(gitConfig, confPath+"/Old Config") - if err != nil { - log.Panic(err) - } - } - - // log.Println(configs) - newConfig := "" - if len(os.Args) > 1 { - action := os.Args[1] - switch action { - case "create": - prom := promptui.Prompt{ - Label: "Profile name", - } - - result, err := prom.Run() - if err != nil { - log.Panic(err) - } - - // File is not exist write - if _, err := os.Stat(confPath + "/" + result); os.IsNotExist(err) { - write(confPath+"/"+result, []byte("[user]\n\tname = "+result)) - } else { - color.HiRed("Profile is already exist") - } - case "delete": - // List git configs - var profiles []string - var pos int - i := 0 - for hash, val := range configs { - // Find current config index - if hash == gitConfigHash { - pos = i - } - profiles = append(profiles, val) - i++ - } - - prompt := promptui.Select{ - Label: "Select Git Config (Current: " + configs[gitConfigHash] + ")", - Items: profiles, - // Change cursor to current config - CursorPos: pos, - HideSelected: true, - } - - _, result, err := prompt.Run() - if err != nil { - log.Panic(err) - } - - prom := promptui.Prompt{ - Label: "Are you sure ? Y/N", - } - asd, err := prom.Run() - if err != nil { - log.Panic(err) - } - if asd == "y" || asd == "Y" { - err = os.Remove(confPath + "/" + result) - if err != nil { - log.Panic(err) - } - color.HiBlue("Profile deleted %q", result) - } else { - color.HiBlue("Profile not deleted %q", result) - } - case "rename": - prom := promptui.Prompt{ - Label: "Profile name", - } - - result, err := prom.Run() - if err != nil { - log.Panic(err) - } - - promD := promptui.Prompt{ - Label: "Desired Profile name", - } - - resultD, err := promD.Run() - if err != nil { - log.Panic(err) - } - - err = os.Rename(confPath+"/"+result, confPath+"/"+resultD) - if err != nil { - log.Panic(err) - } - - } - } else if len(configs) >= 1 { - // List git configs - var profiles []string - var pos int - i := 0 - for hash, val := range configs { - // Find current config index - if hash == gitConfigHash { - pos = i - } - profiles = append(profiles, val) - i++ - } - - prompt := promptui.Select{ - Label: "Select Git Config (Current: " + configs[gitConfigHash] + ")", - Items: profiles, - // Change cursor to current config - CursorPos: pos, - HideSelected: true, - } - - _, result, err := prompt.Run() - - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return - } - newConfig = result - // Remove file for link new one - err = os.Remove(gitConfig) - if err != nil { - log.Panic(err) - } - - // Symbolic link to "~/.gitconfig" - err = os.Symlink(confPath+"/"+newConfig, gitConfig) - if err != nil { - log.Panic(err) - } - color.HiBlue("Switched to profile %q", newConfig) - } -} - -func hash(path string) string { - f, _ := os.Open(path) - h := md5.New() - if _, err := io.Copy(h, f); err != nil { - log.Fatal(err) - } - return hex.EncodeToString(h.Sum(nil)) -} - -func write(file string, data []byte) { - f, e := os.Create(file) - if e != nil { - log.Panic(e) - } - - defer f.Close() - _, err := f.Write(data) - if err != nil { - log.Panic(err) - } + cmd.Execute() } diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..e20436c --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,41 @@ +package utils + +import ( + "crypto/md5" + "encoding/hex" + "io" + "log" + "os" +) + +// Hash generates an MD5 hash of the given file's content. +// It's used to identify git configuration files. +func Hash(path string) string { + f, err := os.Open(path) + if err != nil { + // If the file doesn't exist, we can't hash it. + // Depending on desired behavior, either panic or return an error/empty string. + // For this utility, if a path is given, it's expected to be hashable. + // If it could be a new/empty .gitconfig, os.IsNotExist(err) could be checked. + log.Panicf("Failed to open file %s for hashing: %v", path, err) + } + defer f.Close() // Ensure file is closed + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + log.Fatalf("Failed to copy file content to hasher for %s: %v", path, err) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// Write creates or truncates a file and writes data to it. +func Write(file string, data []byte) { + f, e := os.Create(file) + if e != nil { + log.Panicf("Failed to create file %s: %v", file, e) + } + defer f.Close() // Ensure file is closed + _, err := f.Write(data) + if err != nil { + log.Panicf("Failed to write to file %s: %v", file, err) + } +} diff --git a/utils/utils_test.go b/utils/utils_test.go new file mode 100644 index 0000000..96e7592 --- /dev/null +++ b/utils/utils_test.go @@ -0,0 +1,154 @@ +package utils + +import ( + "crypto/md5" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +func TestHash(t *testing.T) { + tests := []struct { + name string + content string + expected string + }{ + { + name: "simple content", + content: "[user]\n\tname = test\n\temail = test@example.com", + }, + { + name: "empty content", + content: "", + }, + { + name: "special characters", + content: "[user]\n\tname = test-user_123\n\temail = test+tag@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary file + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test.txt") + + // Write content to file + Write(tempFile, []byte(tt.content)) + + // Calculate hash + result := Hash(tempFile) + + // Calculate expected hash manually + h := md5.New() + h.Write([]byte(tt.content)) + expected := hex.EncodeToString(h.Sum(nil)) + + if result != expected { + t.Errorf("Hash() = %v, want %v", result, expected) + } + + // Verify hash is consistent + result2 := Hash(tempFile) + if result != result2 { + t.Errorf("Hash() is not consistent: %v != %v", result, result2) + } + }) + } +} + +func TestHashNonExistentFile(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Hash() should panic when file doesn't exist") + } + }() + + Hash("/nonexistent/file/path") +} + +func TestWrite(t *testing.T) { + tests := []struct { + name string + content []byte + }{ + { + name: "simple text", + content: []byte("hello world"), + }, + { + name: "git config", + content: []byte("[user]\n\tname = test\n\temail = test@example.com"), + }, + { + name: "empty content", + content: []byte(""), + }, + { + name: "binary content", + content: []byte{0, 1, 2, 3, 255, 254, 253}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test.txt") + + // Write content + Write(tempFile, tt.content) + + // Read back and verify + result, err := os.ReadFile(tempFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(result) != string(tt.content) { + t.Errorf("Write() content mismatch, got %v, want %v", result, tt.content) + } + }) + } +} + +func TestWriteOverwrite(t *testing.T) { + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test.txt") + + // Write initial content + Write(tempFile, []byte("initial content")) + + // Verify initial content + result, err := os.ReadFile(tempFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + if string(result) != "initial content" { + t.Errorf("Initial write failed, got %v, want %v", string(result), "initial content") + } + + // Overwrite with new content + Write(tempFile, []byte("new content")) + + // Verify new content + result, err = os.ReadFile(tempFile) + if err != nil { + t.Fatalf("Failed to read file after overwrite: %v", err) + } + if string(result) != "new content" { + t.Errorf("Overwrite failed, got %v, want %v", string(result), "new content") + } +} + +func TestWriteInvalidPath(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Write() should panic when path is invalid") + } + }() + + // Try to write to an invalid path (directory that doesn't exist) + Write("/nonexistent/directory/file.txt", []byte("content")) +} \ No newline at end of file