diff --git a/cmd/gitx/main.go b/cmd/gitx/main.go index 93b2bb0..4c8d359 100644 --- a/cmd/gitx/main.go +++ b/cmd/gitx/main.go @@ -3,13 +3,13 @@ package main import ( "errors" "fmt" - "log" - "os" - "os/exec" - tea "github.com/charmbracelet/bubbletea" + gitxlog "github.com/gitxtui/gitx/internal/log" "github.com/gitxtui/gitx/internal/tui" zone "github.com/lrstanley/bubblezone" + "log" + "os" + "os/exec" ) var version = "dev" @@ -27,6 +27,16 @@ func printHelp() { } func main() { + logFile, err := gitxlog.SetupLogger() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to set up logger: %v\n", err) + } + defer func() { + if err := logFile.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to close log file: %v\n", err) + } + }() + if err := ensureGitRepo(); err != nil { fmt.Fprintln(os.Stderr, err) // print to stderr os.Exit(1) diff --git a/internal/git/branch.go b/internal/git/branch.go index aad9e0f..533b10a 100644 --- a/internal/git/branch.go +++ b/internal/git/branch.go @@ -18,8 +18,7 @@ func (g *GitCommands) GetBranches() ([]*Branch, error) { format := "%(committerdate:relative)\t%(refname:short)\t%(HEAD)" args := []string{"for-each-ref", "--sort=-committerdate", "refs/heads/", fmt.Sprintf("--format=%s", format)} - cmd := ExecCommand("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { return nil, err } @@ -98,71 +97,70 @@ type BranchOptions struct { } // ManageBranch creates or deletes branches. -func (g *GitCommands) ManageBranch(options BranchOptions) (string, error) { +func (g *GitCommands) ManageBranch(options BranchOptions) (string, string, error) { args := []string{"branch"} if options.Delete { + args = append(args, "-d", options.Name) if options.Name == "" { - return "", fmt.Errorf("branch name is required for deletion") + return "", "", fmt.Errorf("branch name is required for deletion") } - args = append(args, "-d", options.Name) } else if options.Create { + args = append(args, options.Name) if options.Name == "" { - return "", fmt.Errorf("branch name is required for creation") + return "", "", fmt.Errorf("branch name is required for creation") } - args = append(args, options.Name) } - cmd := ExecCommand("git", args...) - output, err := cmd.CombinedOutput() + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("branch operation failed: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } // Checkout switches branches or restores working tree files. -func (g *GitCommands) Checkout(branchName string) (string, error) { +func (g *GitCommands) Checkout(branchName string) (string, string, error) { if branchName == "" { - return "", fmt.Errorf("branch name is required") + return "", "", fmt.Errorf("branch name is required") } + args := []string{"checkout", branchName} - cmd := ExecCommand("git", "checkout", branchName) - output, err := cmd.CombinedOutput() + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to checkout branch: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } // Switch switches to a specified branch. -func (g *GitCommands) Switch(branchName string) (string, error) { +func (g *GitCommands) Switch(branchName string) (string, string, error) { if branchName == "" { - return "", fmt.Errorf("branch name is required") + return "", "", fmt.Errorf("branch name is required") } + args := []string{"switch", branchName} - cmd := ExecCommand("git", "switch", branchName) - output, err := cmd.CombinedOutput() + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to switch branch: %v", err) + return string(output), "", err } - return string(output), nil + return string(output), cmdStr, nil } // RenameBranch renames a branch. -func (g *GitCommands) RenameBranch(oldName, newName string) (string, error) { +func (g *GitCommands) RenameBranch(oldName, newName string) (string, string, error) { if oldName == "" || newName == "" { - return "", fmt.Errorf("both old and new branch names are required") + return "", "", fmt.Errorf("both old and new branch names are required") } + args := []string{"branch", "-m", oldName, newName} - cmd := ExecCommand("git", "branch", "-m", oldName, newName) - output, err := cmd.CombinedOutput() + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to rename branch: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } diff --git a/internal/git/clone.go b/internal/git/clone.go index 0751b6c..a10dde5 100644 --- a/internal/git/clone.go +++ b/internal/git/clone.go @@ -2,7 +2,6 @@ package git import ( "fmt" - "os/exec" ) // CloneRepository clones a repository from a given URL into a specified directory. @@ -11,16 +10,14 @@ func (g *GitCommands) CloneRepository(repoURL, directory string) (string, error) return "", fmt.Errorf("repository URL is required") } - var cmd *exec.Cmd + args := []string{"clone", repoURL} if directory != "" { - cmd = exec.Command("git", "clone", repoURL, directory) - } else { - cmd = exec.Command("git", "clone", repoURL) + args = append(args, directory) } - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to clone repository: %v", err) + return string(output), err } return fmt.Sprintf("Successfully cloned repository: %s", repoURL), nil diff --git a/internal/git/commit.go b/internal/git/commit.go index 4327d68..823d8cc 100644 --- a/internal/git/commit.go +++ b/internal/git/commit.go @@ -2,7 +2,6 @@ package git import ( "fmt" - "os/exec" ) // CommitOptions specifies the options for the git commit command. @@ -12,9 +11,9 @@ type CommitOptions struct { } // Commit records changes to the repository. -func (g *GitCommands) Commit(options CommitOptions) (string, error) { +func (g *GitCommands) Commit(options CommitOptions) (string, string, error) { if options.Message == "" && !options.Amend { - return "", fmt.Errorf("commit message is required unless amending") + return "", "", fmt.Errorf("commit message is required unless amending") } args := []string{"commit"} @@ -27,13 +26,12 @@ func (g *GitCommands) Commit(options CommitOptions) (string, error) { args = append(args, "-m", options.Message) } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to commit changes: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } // ShowCommit shows the details of a specific commit. @@ -41,11 +39,11 @@ func (g *GitCommands) ShowCommit(commitHash string) (string, error) { if commitHash == "" { commitHash = "HEAD" } + args := []string{"show", "--color=always", commitHash} - cmd := exec.Command("git", "show", "--color=always", commitHash) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to show commit: %v", err) + return string(output), err } return string(output), nil diff --git a/internal/git/diff.go b/internal/git/diff.go index 2382bda..d539018 100644 --- a/internal/git/diff.go +++ b/internal/git/diff.go @@ -1,10 +1,5 @@ package git -import ( - "fmt" - "os/exec" -) - // DiffOptions specifies the options for the git diff command. type DiffOptions struct { Commit1 string @@ -39,10 +34,9 @@ func (g *GitCommands) ShowDiff(options DiffOptions) (string, error) { args = append(args, options.Commit2) } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to get diff: %v", err) + return string(output), err } return string(output), nil diff --git a/internal/git/files.go b/internal/git/files.go index 6caf986..bd00fc6 100644 --- a/internal/git/files.go +++ b/internal/git/files.go @@ -2,13 +2,13 @@ package git import ( "fmt" - "os/exec" ) // ListFiles shows information about files in the index and the working tree. func (g *GitCommands) ListFiles() (string, error) { - cmd := exec.Command("git", "ls-files") - output, err := cmd.CombinedOutput() + args := []string{"ls-files"} + + output, _, err := g.executeCommand(args...) if err != nil { return string(output), fmt.Errorf("failed to list files: %v", err) } @@ -22,10 +22,10 @@ func (g *GitCommands) BlameFile(filePath string) (string, error) { return "", fmt.Errorf("file path is required") } - cmd := exec.Command("git", "blame", filePath) - output, err := cmd.CombinedOutput() + args := []string{"blame", filePath} + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to blame file: %v", err) + return string(output), err } return string(output), nil diff --git a/internal/git/git.go b/internal/git/git.go index 9083c00..d766b67 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,7 +1,11 @@ package git import ( + "errors" + "fmt" + "log" "os/exec" + "strings" ) // ExecCommand is a variable that holds the exec.Command function @@ -15,3 +19,36 @@ type GitCommands struct{} func NewGitCommands() *GitCommands { return &GitCommands{} } + +// executeCommand centralizes the execution of all git commands and serves +// as a single point for logging. It takes a list of flags passed to the git +// command as arguments and returns 1. standard output, 2. the command string +// and 3. standard error +func (g *GitCommands) executeCommand(args ...string) (string, string, error) { + cmdStr := "git " + strings.Join(args, " ") + log.Printf("Executing command: %s", cmdStr) + + cmd := ExecCommand("git", args...) + output, err := cmd.CombinedOutput() + + if err != nil { + log.Printf("Error: %v, Output: %s", err, string(output)) + + var exitErr *exec.ExitError + exitCode := 0 + + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } + + gitMsg := strings.TrimSpace(string(output)) + gitMsg = strings.TrimPrefix(gitMsg, "fatal: ") + gitMsg = strings.TrimPrefix(gitMsg, "error: ") + + detailedError := fmt.Errorf("[ERROR - %d] %s", exitCode, gitMsg) + + return "", cmdStr, detailedError + } + + return string(output), cmdStr, nil +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 8130b88..8d7c53f 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -167,7 +167,7 @@ func TestGitCommands_Commit(t *testing.T) { g := NewGitCommands() // Test empty commit message - if _, err := g.Commit(CommitOptions{}); err == nil { + if _, _, err := g.Commit(CommitOptions{}); err == nil { t.Error("Commit() with empty message should fail") } @@ -178,10 +178,10 @@ func TestGitCommands_Commit(t *testing.T) { if err := os.WriteFile("commit-test.txt", []byte("amended content"), 0644); err != nil { t.Fatalf("failed to amend test file: %v", err) } - if _, err := g.AddFiles([]string{"commit-test.txt"}); err != nil { + if _, _, err := g.AddFiles([]string{"commit-test.txt"}); err != nil { t.Fatalf("failed to add amended file: %v", err) } - if _, err := g.Commit(CommitOptions{Amend: true, Message: "Amended commit"}); err != nil { + if _, _, err := g.Commit(CommitOptions{Amend: true, Message: "Amended commit"}); err != nil { t.Errorf("Commit() with amend failed: %v", err) } } @@ -196,22 +196,22 @@ func TestGitCommands_BranchAndCheckout(t *testing.T) { branchName := "feature-branch" // Create branch - if _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { + if _, _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { t.Fatalf("ManageBranch() create failed: %v", err) } // Checkout branch - if _, err := g.Checkout(branchName); err != nil { + if _, _, err := g.Checkout(branchName); err != nil { t.Fatalf("Checkout() failed: %v", err) } // Switch back to main/master - if _, err := g.Switch("master"); err != nil { + if _, _, err := g.Switch("master"); err != nil { t.Fatalf("Switch() failed: %v", err) } // Delete branch - if _, err := g.ManageBranch(BranchOptions{Delete: true, Name: branchName}); err != nil { + if _, _, err := g.ManageBranch(BranchOptions{Delete: true, Name: branchName}); err != nil { t.Fatalf("ManageBranch() delete failed: %v", err) } } @@ -227,16 +227,16 @@ func TestGitCommands_Merge(t *testing.T) { // Create feature branch and commit branchName := "feature" - if _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { + if _, _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { t.Fatalf("failed to create branch: %v", err) } - if _, err := g.Checkout(branchName); err != nil { + if _, _, err := g.Checkout(branchName); err != nil { t.Fatalf("failed to checkout branch: %v", err) } createAndCommitFile(t, g, "feature.txt", "feature content", "feature commit") // Switch back to master and make another commit - if _, err := g.Checkout("master"); err != nil { + if _, _, err := g.Checkout("master"); err != nil { t.Fatalf("failed to checkout master: %v", err) } createAndCommitFile(t, g, "master2.txt", "master2 content", "master2 commit") @@ -262,22 +262,22 @@ func TestGitCommands_Rebase(t *testing.T) { // Create feature branch and commit branchName := "feature" - if _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { + if _, _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { t.Fatalf("failed to create branch: %v", err) } - if _, err := g.Checkout(branchName); err != nil { + if _, _, err := g.Checkout(branchName); err != nil { t.Fatalf("failed to checkout branch: %v", err) } createAndCommitFile(t, g, "feature.txt", "feature content", "feature commit") // Switch back to master and make another commit - if _, err := g.Checkout("master"); err != nil { + if _, _, err := g.Checkout("master"); err != nil { t.Fatalf("failed to checkout master: %v", err) } createAndCommitFile(t, g, "master2.txt", "master2 content", "master2 commit") // Switch to feature branch and rebase onto master - if _, err := g.Checkout(branchName); err != nil { + if _, _, err := g.Checkout(branchName); err != nil { t.Fatalf("failed to checkout feature branch: %v", err) } output, err := g.Rebase(RebaseOptions{BranchName: "master"}) @@ -300,12 +300,12 @@ func TestGitCommands_FileOperations(t *testing.T) { if err := os.WriteFile("new-file.txt", []byte("new"), 0644); err != nil { t.Fatalf("failed to create new file: %v", err) } - if _, err := g.AddFiles([]string{"new-file.txt"}); err != nil { + if _, _, err := g.AddFiles([]string{"new-file.txt"}); err != nil { t.Errorf("AddFiles() failed: %v", err) } // Test Reset - if _, err := g.ResetFiles([]string{"new-file.txt"}); err != nil { + if _, _, err := g.ResetFiles([]string{"new-file.txt"}); err != nil { t.Errorf("ResetFiles() failed: %v", err) } @@ -340,12 +340,12 @@ func TestGitCommands_Stash(t *testing.T) { } // Stash push - if _, err := g.Stash(StashOptions{Push: true, Message: "test stash"}); err != nil { + if _, _, err := g.Stash(StashOptions{Push: true, Message: "test stash"}); err != nil { t.Fatalf("Stash() push failed: %v", err) } // Stash apply - if _, err := g.Stash(StashOptions{Apply: true}); err != nil { + if _, _, err := g.Stash(StashOptions{Apply: true}); err != nil { t.Errorf("Stash() apply failed: %v", err) } } diff --git a/internal/git/init.go b/internal/git/init.go index 7a823c8..30240d3 100644 --- a/internal/git/init.go +++ b/internal/git/init.go @@ -10,11 +10,11 @@ func (g *GitCommands) InitRepository(path string) (string, error) { if path == "" { path = "." } + args := []string{"init", path} - cmd := ExecCommand("git", "init", path) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to initialize repository: %v", err) + return string(output), err } absPath, _ := filepath.Abs(path) diff --git a/internal/git/log.go b/internal/git/log.go index a7755a9..b430b49 100644 --- a/internal/git/log.go +++ b/internal/git/log.go @@ -72,10 +72,9 @@ func (g *GitCommands) ShowLog(options LogOptions) (string, error) { args = append(args, options.Branch) } - cmd := ExecCommand("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to get log: %w", err) + return string(output), err } return string(output), nil diff --git a/internal/git/merge.go b/internal/git/merge.go index 532930d..dca7999 100644 --- a/internal/git/merge.go +++ b/internal/git/merge.go @@ -2,7 +2,6 @@ package git import ( "fmt" - "os/exec" ) // MergeOptions specifies the options for the git merge command. @@ -30,8 +29,7 @@ func (g *GitCommands) Merge(options MergeOptions) (string, error) { args = append(args, options.BranchName) - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { return string(output), fmt.Errorf("failed to merge branch: %v", err) } @@ -64,10 +62,9 @@ func (g *GitCommands) Rebase(options RebaseOptions) (string, error) { args = append(args, options.BranchName) } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to rebase branch: %v", err) + return string(output), err } return string(output), nil diff --git a/internal/git/remote.go b/internal/git/remote.go index 44ab925..06a2a58 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -2,7 +2,6 @@ package git import ( "fmt" - "os/exec" ) // RemoteOptions specifies the options for managing remotes. @@ -34,10 +33,9 @@ func (g *GitCommands) ManageRemote(options RemoteOptions) (string, error) { args = append(args, "remove", options.Name) } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("remote operation failed: %v", err) + return string(output), err } return string(output), nil @@ -55,10 +53,9 @@ func (g *GitCommands) Fetch(remote string, branch string) (string, error) { args = append(args, branch) } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to fetch: %v", err) + return string(output), err } return string(output), nil @@ -87,10 +84,9 @@ func (g *GitCommands) Pull(options PullOptions) (string, error) { args = append(args, options.Branch) } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to pull: %v", err) + return string(output), err } return string(output), nil @@ -129,10 +125,9 @@ func (g *GitCommands) Push(options PushOptions) (string, error) { args = append(args, options.Branch) } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to push: %v", err) + return string(output), err } return string(output), nil diff --git a/internal/git/repo.go b/internal/git/repo.go index ddd3561..41eef1c 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -8,39 +8,37 @@ import ( // GetRepoInfo returns the current repository and active branch name. func (g *GitCommands) GetRepoInfo() (repoName string, branchName string, err error) { // Get the root dir of the repo. - repoPathBytes, err := ExecCommand("git", "rev-parse", "--show-toplevel").Output() + repoPath, _, err := g.executeCommand("rev-parse", "--show-toplevel") if err != nil { return "", "", err } - repoPath := strings.TrimSpace(string(repoPathBytes)) - + repoPath = strings.TrimSpace(repoPath) repoName = filepath.Base(repoPath) // Get the current branch name. - repoBranchBytes, err := ExecCommand("git", "rev-parse", "--abbrev-ref", "HEAD").Output() + branchName, _, err = g.executeCommand("rev-parse", "--abbrev-ref", "HEAD") if err != nil { return "", "", err } - branchName = strings.TrimSpace(string(repoBranchBytes)) + branchName = strings.TrimSpace(branchName) return repoName, branchName, nil } func (g *GitCommands) GetGitRepoPath() (repoPath string, err error) { - repoPathBytes, err := ExecCommand("git", "rev-parse", "--git-dir").Output() + repoPath, _, err = g.executeCommand("rev-parse", "--git-dir") if err != nil { return "", err } - repoPath = strings.TrimSpace(string(repoPathBytes)) + repoPath = strings.TrimSpace(repoPath) return repoPath, nil } // GetUserName returns the user's name from the git config. func (g *GitCommands) GetUserName() (string, error) { - cmd := ExecCommand("git", "config", "user.name") - output, err := cmd.Output() + userName, _, err := g.executeCommand("config", "user.name") if err != nil { return "", err } - return strings.TrimSpace(string(output)), nil + return strings.TrimSpace(userName), nil } diff --git a/internal/git/stage.go b/internal/git/stage.go index 8127d5a..859f5e9 100644 --- a/internal/git/stage.go +++ b/internal/git/stage.go @@ -2,39 +2,38 @@ package git import ( "fmt" - "os/exec" ) // AddFiles adds file contents to the index (staging area). -func (g *GitCommands) AddFiles(paths []string) (string, error) { +func (g *GitCommands) AddFiles(paths []string) (string, string, error) { if len(paths) == 0 { paths = []string{"."} } args := append([]string{"add"}, paths...) - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to add files: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } // ResetFiles resets the current HEAD to the specified state, unstaging files. -func (g *GitCommands) ResetFiles(paths []string) (string, error) { +func (g *GitCommands) ResetFiles(paths []string) (string, string, error) { if len(paths) == 0 { - return "", fmt.Errorf("at least one file path is required") + return "", "", fmt.Errorf("at least one file path is required") } args := append([]string{"reset"}, paths...) - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to unstage files: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } // RemoveFiles removes files from the working tree and from the index. @@ -51,10 +50,9 @@ func (g *GitCommands) RemoveFiles(paths []string, cached bool) (string, error) { args = append(args, paths...) - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to remove files: %v", err) + return string(output), err } return string(output), nil @@ -66,10 +64,11 @@ func (g *GitCommands) MoveFile(source, destination string) (string, error) { return "", fmt.Errorf("source and destination paths are required") } - cmd := exec.Command("git", "mv", source, destination) - output, err := cmd.CombinedOutput() + args := []string{"mv", source, destination} + + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to move file: %v", err) + return string(output), err } return string(output), nil @@ -84,9 +83,9 @@ type RestoreOptions struct { } // Restore restores working tree files. -func (g *GitCommands) Restore(options RestoreOptions) (string, error) { +func (g *GitCommands) Restore(options RestoreOptions) (string, string, error) { if len(options.Paths) == 0 { - return "", fmt.Errorf("at least one file path is required") + return "", "", fmt.Errorf("at least one file path is required") } args := []string{"restore"} @@ -105,41 +104,42 @@ func (g *GitCommands) Restore(options RestoreOptions) (string, error) { args = append(args, options.Paths...) - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to restore files: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } // Revert is used to record some new commits to reverse the effect of some earlier commits. -func (g *GitCommands) Revert(commitHash string) (string, error) { +func (g *GitCommands) Revert(commitHash string) (string, string, error) { if commitHash == "" { - return "", fmt.Errorf("commit hash is required") + return "", "", fmt.Errorf("commit hash is required") } - cmd := exec.Command("git", "revert", commitHash) - output, err := cmd.CombinedOutput() + args := []string{"revert", commitHash} + + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to revert commit: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } // ResetToCommit resets the current HEAD to the specified commit. -func (g *GitCommands) ResetToCommit(commitHash string) (string, error) { +func (g *GitCommands) ResetToCommit(commitHash string) (string, string, error) { if commitHash == "" { - return "", fmt.Errorf("commit hash is required") + return "", "", fmt.Errorf("commit hash is required") } - cmd := exec.Command("git", "reset", "--hard", commitHash) - output, err := cmd.CombinedOutput() + args := []string{"reset", "--hard", commitHash} + + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to reset to commit: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } diff --git a/internal/git/stash.go b/internal/git/stash.go index 7e8931f..4f4c20b 100644 --- a/internal/git/stash.go +++ b/internal/git/stash.go @@ -2,7 +2,6 @@ package git import ( "fmt" - "os/exec" "strings" ) @@ -20,8 +19,9 @@ func (g *GitCommands) GetStashes() ([]*Stash, error) { // Message: WIP on master: 52f3a6b feat: add panels // We use a unique delimiter to reliably parse the multi-line output for each stash. format := "%gD%n%gs" - cmd := ExecCommand("git", "stash", "list", fmt.Sprintf("--format=%s", format)) - output, err := cmd.CombinedOutput() + args := []string{"stash", "list", fmt.Sprintf("--format=%s", format)} + + output, _, err := g.executeCommand(args...) if err != nil { return nil, err } @@ -59,7 +59,7 @@ type StashOptions struct { } // Stash saves your local modifications away and reverts the working directory to match the HEAD commit. -func (g *GitCommands) Stash(options StashOptions) (string, error) { +func (g *GitCommands) Stash(options StashOptions) (string, string, error) { if !options.Push && !options.Pop && !options.Apply && !options.List && !options.Show && !options.Drop { options.Push = true } @@ -95,25 +95,25 @@ func (g *GitCommands) Stash(options StashOptions) (string, error) { } } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, cmdStr, err := g.executeCommand(args...) if err != nil { // The command fails if there's no stash. if strings.Contains(string(output), "No stash entries found") || strings.Contains(string(output), "No stash found") { - return "No stashes found.", nil + return "No stashes found.", cmdStr, nil } - return string(output), fmt.Errorf("stash operation failed: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } // StashAll stashes all changes, including untracked files. -func (g *GitCommands) StashAll() (string, error) { - cmd := exec.Command("git", "stash", "push", "-u", "-m", "gitx auto stash") - output, err := cmd.CombinedOutput() +func (g *GitCommands) StashAll() (string, string, error) { + args := []string{"stash", "push", "-u", "-m", "gitx auto stash"} + + output, cmdStr, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("failed to stash all changes: %v", err) + return string(output), cmdStr, err } - return string(output), nil + return string(output), cmdStr, nil } diff --git a/internal/git/status.go b/internal/git/status.go index d7517fe..81b038a 100644 --- a/internal/git/status.go +++ b/internal/git/status.go @@ -11,8 +11,8 @@ func (g *GitCommands) GetStatus(options StatusOptions) (string, error) { if options.Porcelain { args = append(args, "--porcelain") } - cmd := ExecCommand("git", args...) - output, err := cmd.CombinedOutput() + + output, _, err := g.executeCommand(args...) if err != nil { return string(output), err } diff --git a/internal/git/tag.go b/internal/git/tag.go index f60fc32..0a7f7ef 100644 --- a/internal/git/tag.go +++ b/internal/git/tag.go @@ -2,7 +2,6 @@ package git import ( "fmt" - "os/exec" ) // TagOptions specifies the options for managing tags. @@ -36,10 +35,9 @@ func (g *GitCommands) ManageTag(options TagOptions) (string, error) { } } - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() + output, _, err := g.executeCommand(args...) if err != nil { - return string(output), fmt.Errorf("tag operation failed: %v", err) + return string(output), err } return string(output), nil diff --git a/internal/git/testing.go b/internal/git/testing.go index e2710cb..65e7db2 100644 --- a/internal/git/testing.go +++ b/internal/git/testing.go @@ -50,10 +50,10 @@ func setupTestRepo(t *testing.T) (string, func()) { if err := os.WriteFile("initial.txt", []byte("initial content"), 0644); err != nil { t.Fatalf("failed to create initial file: %v", err) } - if _, err := g.AddFiles([]string{"initial.txt"}); err != nil { + if _, _, err := g.AddFiles([]string{"initial.txt"}); err != nil { t.Fatalf("failed to add initial file: %v", err) } - if _, err := g.Commit(CommitOptions{Message: "Initial commit"}); err != nil { + if _, _, err := g.Commit(CommitOptions{Message: "Initial commit"}); err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -123,10 +123,10 @@ func createAndCommitFile(t *testing.T, g *GitCommands, filename, content, messag if err := os.WriteFile(filename, []byte(content), 0644); err != nil { t.Fatalf("failed to create test file %s: %v", filename, err) } - if _, err := g.AddFiles([]string{filename}); err != nil { + if _, _, err := g.AddFiles([]string{filename}); err != nil { t.Fatalf("failed to add file %s: %v", filename, err) } - if _, err := g.Commit(CommitOptions{Message: message}); err != nil { + if _, _, err := g.Commit(CommitOptions{Message: message}); err != nil { t.Fatalf("failed to commit file %s: %v", filename, err) } } diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..b93bf9c --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,28 @@ +package log + +import ( + "log" + "os" + "path/filepath" +) + +// SetupLogger sets up a logfile in the user's home directory. +// Also configures the standard logger to write the logs to the log file +func SetupLogger() (*os.File, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + logFilePath := filepath.Join(home, ".gitx.log") + + file, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) + if err != nil { + return nil, err + } + + log.SetOutput(file) + log.Println("--- Gitx session started ---") + + return file, nil +} diff --git a/internal/tui/constants.go b/internal/tui/constants.go index fa7e563..465600f 100644 --- a/internal/tui/constants.go +++ b/internal/tui/constants.go @@ -81,8 +81,8 @@ const ( ██████╗ ██╗████████╗██╗ ██╗ ██╔════╝ ██║╚══██╔══╝╚██╗██╔╝ - ██║ ███╗██║ ██║ ╚███╔╝ - ██║ ██║██║ ██║ ██╔██╗ + ██║ ███╗██║ ██║ ╚███╔╝ + ██║ ██║██║ ██║ ██╔██╗ ╚██████╔╝██║ ██║ ██╔╝ ██╗ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ @@ -100,6 +100,6 @@ const ( ▐▌ █ █ ▄ ▄ ▐▌▝▜▌ █ █ ▀▄▀ ▝▚▄▞▘▗▄█▄▖ █ ▄▀ ▀▄ - + ` ) diff --git a/internal/tui/model.go b/internal/tui/model.go index f892ddc..084df2f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -47,6 +47,8 @@ type Model struct { inputCallback func(string) tea.Cmd commitCallback func(title, description string) tea.Cmd confirmCallback func(bool) tea.Cmd + // New fields for command history + CommandHistory []string } // initialModel creates the initial state of the application. @@ -93,6 +95,9 @@ func initialModel() Model { ta.SetWidth(80) ta.SetHeight(5) + historyVP := viewport.New(0, 0) + historyVP.SetContent("Command history will appear here...") + return Model{ theme: Themes[selectedThemeName], themeNames: themeNames, @@ -109,6 +114,7 @@ func initialModel() Model { mode: modeNormal, textInput: ti, descriptionInput: ta, + CommandHistory: []string{}, } } diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 6fc1c3d..953b4fa 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -130,6 +130,7 @@ type Theme struct { ActiveBorder BorderStyle InactiveBorder BorderStyle Tree TreeStyle + ErrorText lipgloss.Style } // BorderStyle defines the characters and styles for a panel's border. @@ -229,6 +230,7 @@ func NewThemeFromPalette(p Palette) Theme { Prefix: treePrefix, PrefixLast: treePrefixLast, }, + ErrorText: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightRed)), } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 1bb97cd..3dd138c 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -7,12 +7,18 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/gitxtui/gitx/internal/git" zone "github.com/lrstanley/bubblezone" ) var keys = DefaultKeyMap() +// commandExecutedMsg is sent after a git command has been run successfully. +type commandExecutedMsg struct { + cmdStr string +} + // panelContentUpdatedMsg is sent when new content for a panel has been fetched. type panelContentUpdatedMsg struct { panel Panel @@ -55,10 +61,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { oldFocus := m.focusedPanel switch msg := msg.(type) { + + case commandExecutedMsg: + // A command was successful, add it to our history. + m.CommandHistory = append([]string{msg.cmdStr}, m.CommandHistory...) + // Update the history viewport content. + historyContent := strings.Join(m.CommandHistory, "\n\n") + m.panels[SecondaryPanel].content = historyContent + m.panels[SecondaryPanel].viewport.SetContent(historyContent) + m.panels[SecondaryPanel].viewport.GotoTop() + + // Trigger a refresh of all + // relevant panels from this central location. + return m, tea.Batch( + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(CommitsPanel), + m.fetchPanelContent(BranchesPanel), + m.fetchPanelContent(StatusPanel), + ) + case errMsg: // You can improve this to show errors in the UI log.Printf("error: %v", msg) - return m, nil + + errorLine := m.theme.ErrorText.Render(msg.Error()) + m.CommandHistory = append([]string{errorLine}, m.CommandHistory...) + + // Update the history panel's content and scroll to the new error. + rawHistoryContent := strings.Join(m.CommandHistory, "\n\n") + contentWidth := m.panels[SecondaryPanel].viewport.Width + wrappedContent := lipgloss.NewStyle().Width(contentWidth).Render(rawHistoryContent) + + m.panels[SecondaryPanel].content = wrappedContent + m.panels[SecondaryPanel].viewport.SetContent(wrappedContent) + m.panels[SecondaryPanel].viewport.GotoTop() + return m, tea.Batch( + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(CommitsPanel), + m.fetchPanelContent(BranchesPanel), + m.fetchPanelContent(StatusPanel), + ) case mainContentUpdatedMsg: m.panels[MainPanel].content = msg.content @@ -365,12 +407,12 @@ func (m Model) fetchPanelContent(panel Panel) tea.Cmd { } } case SecondaryPanel: - url := m.theme.Hyperlink.Render(githubMainPage) - content = strings.Join([]string{ - "\t--- Feature in development! ---", - "\n\t* This panel will contain all the command logs and history for of TUI app.", - fmt.Sprintf("\t* visit for more details: %s.", url), - }, "\n") + if len(m.CommandHistory) == 0 { + content = "Command history will appear here..." + } else { + content = strings.Join(m.CommandHistory, "\n") + } + err = nil // Set err to nil as there's no operation that can fail here. } if err != nil { @@ -446,7 +488,7 @@ func (m *Model) updateMainPanel() tea.Cmd { parts := strings.SplitN(line, "\t", 2) if len(parts) > 0 { stashID := parts[0] - content, err = m.git.Stash(git.StashOptions{Show: true, StashID: stashID}) + content, _, err = m.git.Stash(git.StashOptions{Show: true, StashID: stashID}) } } } @@ -606,39 +648,44 @@ func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { if description != "" { commitMsg = title + "\n\n" + description } - _, err := m.git.Commit(git.CommitOptions{Message: commitMsg}) + _, cmdStr, err := m.git.Commit(git.CommitOptions{Message: commitMsg}) if err != nil { return errMsg{err} } - return tea.Batch( - m.fetchPanelContent(FilesPanel), - m.fetchPanelContent(CommitsPanel), - m.fetchPanelContent(SecondaryPanel), - ) + return commandExecutedMsg{cmdStr} } } case key.Matches(msg, keys.StageItem): - // If the item is unstaged, stage it, and vice-versa. - if status[0] == ' ' || status[0] == '?' { - _, err := m.git.AddFiles([]string{filePath}) - if err != nil { - return func() tea.Msg { return errMsg{err} } - } - } else { - _, err := m.git.ResetFiles([]string{filePath}) - if err != nil { - return func() tea.Msg { return errMsg{err} } - } - } - return m.fetchPanelContent(FilesPanel) + return tea.Batch( + func() tea.Msg { + var cmdStr string + var err error + if status[0] == ' ' || status[0] == '?' { + _, cmdStr, err = m.git.AddFiles([]string{filePath}) + } else { + _, cmdStr, err = m.git.ResetFiles([]string{filePath}) + } + if err != nil { + return errMsg{err} + } + return commandExecutedMsg{cmdStr} + }, + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(StatusPanel), + ) case key.Matches(msg, keys.StageAll): - _, err := m.git.AddFiles([]string{"."}) - if err != nil { - return func() tea.Msg { return errMsg{err} } - } - return m.fetchPanelContent(FilesPanel) + return tea.Batch( + func() tea.Msg { + _, cmdStr, err := m.git.AddFiles([]string{"."}) + if err != nil { + return errMsg{err} + } + return commandExecutedMsg{cmdStr} + }, + m.fetchPanelContent(FilesPanel), + ) case key.Matches(msg, keys.Discard): m.mode = modeConfirm @@ -649,23 +696,26 @@ func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { return nil } return func() tea.Msg { - _, err := m.git.Restore(git.RestoreOptions{ + _, cmdStr, err := m.git.Restore(git.RestoreOptions{ Paths: []string{filePath}, WorkingDir: true, }) if err != nil { return errMsg{err} } - return m.fetchPanelContent(FilesPanel) + return commandExecutedMsg{cmdStr} } } case key.Matches(msg, keys.StashAll): - _, err := m.git.StashAll() - if err != nil { - return func() tea.Msg { return errMsg{err} } - } return tea.Batch( + func() tea.Msg { + _, cmdStr, err := m.git.StashAll() + if err != nil { + return errMsg{err} + } + return commandExecutedMsg{cmdStr} + }, m.fetchPanelContent(FilesPanel), m.fetchPanelContent(StashPanel), ) @@ -690,11 +740,14 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { switch { case key.Matches(msg, keys.Checkout): - _, err := m.git.Checkout(branchName) - if err != nil { - return func() tea.Msg { return errMsg{err} } - } return tea.Batch( + func() tea.Msg { + _, cmdStr, err := m.git.Checkout(branchName) + if err != nil { + return errMsg{err} + } + return commandExecutedMsg{cmdStr} + }, m.fetchPanelContent(StatusPanel), m.fetchPanelContent(BranchesPanel), m.fetchPanelContent(CommitsPanel), @@ -710,23 +763,22 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { if input == "" { return nil } - return func() tea.Msg { - // Create the new branch - _, err := m.git.ManageBranch(git.BranchOptions{Create: true, Name: input}) - if err != nil { - return errMsg{err} - } - // checkout to the new branch - _, err = m.git.Checkout(input) - if err != nil { - return errMsg{err} - } - return tea.Batch( - m.fetchPanelContent(BranchesPanel), - m.fetchPanelContent(StatusPanel), - m.fetchPanelContent(CommitsPanel), - ) - } + return tea.Sequence( + func() tea.Msg { + _, cmdStr, err := m.git.ManageBranch(git.BranchOptions{Create: true, Name: input}) + if err != nil { + return errMsg{err} + } + return commandExecutedMsg{cmdStr} + }, + func() tea.Msg { + _, cmdStr, err := m.git.Checkout(input) + if err != nil { + return errMsg{err} + } + return commandExecutedMsg{cmdStr} + }, + ) } case key.Matches(msg, keys.DeleteBranch): @@ -738,11 +790,11 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { return nil } return func() tea.Msg { - _, err := m.git.ManageBranch(git.BranchOptions{Delete: true, Name: branchName}) + _, cmdStr, err := m.git.ManageBranch(git.BranchOptions{Delete: true, Name: branchName}) if err != nil { return errMsg{err} } - return m.fetchPanelContent(BranchesPanel) + return commandExecutedMsg{cmdStr} } } @@ -757,14 +809,11 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { return nil } return func() tea.Msg { - _, err := m.git.RenameBranch(branchName, input) + _, cmdStr, err := m.git.RenameBranch(branchName, input) if err != nil { return errMsg{err} } - return tea.Batch( - m.fetchPanelContent(BranchesPanel), - m.fetchPanelContent(StatusPanel), - ) + return commandExecutedMsg{cmdStr} } } } @@ -798,15 +847,11 @@ func (m *Model) handleCommitsPanelKeys(msg tea.KeyMsg) tea.Cmd { if description != "" { commitMsg = title + "\n\n" + description } - _, err := m.git.Commit(git.CommitOptions{Message: commitMsg, Amend: true}) + _, cmdStr, err := m.git.Commit(git.CommitOptions{Message: commitMsg, Amend: true}) if err != nil { return errMsg{err} } - return tea.Batch( - m.fetchPanelContent(CommitsPanel), - m.fetchPanelContent(FilesPanel), - m.fetchPanelContent(SecondaryPanel), - ) + return commandExecutedMsg{cmdStr} } } @@ -819,14 +864,11 @@ func (m *Model) handleCommitsPanelKeys(msg tea.KeyMsg) tea.Cmd { return nil } return func() tea.Msg { - _, err := m.git.Revert(sha) + _, cmdStr, err := m.git.Revert(sha) if err != nil { return errMsg{err} } - return tea.Batch( - m.fetchPanelContent(CommitsPanel), - m.fetchPanelContent(FilesPanel), - ) + return commandExecutedMsg{cmdStr} } } @@ -839,15 +881,11 @@ func (m *Model) handleCommitsPanelKeys(msg tea.KeyMsg) tea.Cmd { return nil } return func() tea.Msg { - _, err := m.git.ResetToCommit(sha) + _, cmdStr, err := m.git.ResetToCommit(sha) if err != nil { return errMsg{err} } - return tea.Batch( - m.fetchPanelContent(CommitsPanel), - m.fetchPanelContent(FilesPanel), - m.fetchPanelContent(StatusPanel), - ) + return commandExecutedMsg{cmdStr} } } } @@ -871,21 +909,27 @@ func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { switch { case key.Matches(msg, keys.StashApply): - _, err := m.git.Stash(git.StashOptions{Apply: true, StashID: stashID}) - if err != nil { - return func() tea.Msg { return errMsg{err} } - } return tea.Batch( + func() tea.Msg { + _, cmdStr, err := m.git.Stash(git.StashOptions{Apply: true, StashID: stashID}) + if err != nil { + return errMsg{err} + } + return commandExecutedMsg{cmdStr} + }, m.fetchPanelContent(FilesPanel), m.fetchPanelContent(StashPanel), ) case key.Matches(msg, keys.StashPop): - _, err := m.git.Stash(git.StashOptions{Pop: true, StashID: stashID}) - if err != nil { - return func() tea.Msg { return errMsg{err} } - } return tea.Batch( + func() tea.Msg { + _, cmdStr, err := m.git.Stash(git.StashOptions{Pop: true, StashID: stashID}) + if err != nil { + return errMsg{err} + } + return commandExecutedMsg{cmdStr} + }, m.fetchPanelContent(FilesPanel), m.fetchPanelContent(StashPanel), ) @@ -899,7 +943,7 @@ func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { return nil } return func() tea.Msg { - _, err := m.git.Stash(git.StashOptions{Drop: true, StashID: stashID}) + _, cmdStr, err := m.git.Stash(git.StashOptions{Drop: true, StashID: stashID}) if err != nil { return errMsg{err} } @@ -907,7 +951,7 @@ func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { if m.panels[StashPanel].cursor >= len(m.panels[StashPanel].lines)-1 && m.panels[StashPanel].cursor > 0 { m.panels[StashPanel].cursor-- } - return m.fetchPanelContent(StashPanel) + return commandExecutedMsg{cmdStr} } } } diff --git a/internal/tui/view.go b/internal/tui/view.go index cd20c8a..0f50e35 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -21,6 +21,7 @@ func stripAnsi(str string) string { // View is the main render function for the application. func (m Model) View() string { + var finalView string if m.showHelp { finalView = m.renderHelpView() @@ -107,7 +108,7 @@ func (m Model) renderMainView() string { titles := map[Panel]string{ MainPanel: "Main", StatusPanel: "Status", FilesPanel: "Files", - BranchesPanel: "Branches", CommitsPanel: "Commits", StashPanel: "Stash", SecondaryPanel: "Secondary", + BranchesPanel: "Branches", CommitsPanel: "Commits", StashPanel: "Stash", SecondaryPanel: "Command History", } leftColumn := m.renderPanelColumn(leftpanels, titles, leftSectionWidth)