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