\$\begingroup\$

TLDR

Python developer; first project in Go; looking for feedback :)

Repo

Overview

I just started learning Go (need to use it at work). As a fan of project-based learning, I put together a small program to practice writing Go and setting up a project from scratch.

As a Python developer, many of Go's elements are new to me (structs, interfaces, etc) - not to mention static typing and compilation.

I was hoping someone could review the code & project organization and share some constructive feedback. Thanks in advance!

What I am interested in:

Improvements to overall program structure

Improvements to non-idiomatic uses of the language

Improvements to usage of types

Error handling?

Any other feedback you might have!

Not interested in:

Replacing code with libraries - I know there some libraries that help with CLI interface, args parsing etc. but I wanted to build this the hard way since this is primarily a learning exercise :)

The Program

A simple CLI tool to help you manage and jump around your local projects.

Usage

Create Projects

$ pm add newproject ~/code/repos/newproject $ pm add another ~/code/go/src/github.com/username/another

Remove project

$ pm remove newproject

pm add and pm remove is used to implicitly manage a json file with those configs. This config file is used by other commands to track "projects".

~/.pm.json

{ "projects": [ { "path": "~/code/repos/newproject", "name": "newproject" }, { "path": "~/code/go/src/github.com/username/another", "name": "another" } ] }

Go to project (finds project in config and starts new shell at config location

$ pm go newproject Starting Shell... ~/code/repos/newproject $ $ pm go another Starting Shell... ~/code/go/src/github.com/username/another $

List project (shows list of projects)

$ pm list newproject another

Code

The full program is on this repo but I copied all relevant files below.

Package Structure

pm/ + main.go + cmd/ + main.go + internal/ + commands/ + cli/ + config/

Entry Point

// main.go package main import ( "github.com/gtalarico/pm/cmd" ) func main() { cmd.Run() }

Cmd Entry Point

// cmd/main.go package cmd import ( "os" "github.com/gtalarico/pm/internal/cli" "github.com/gtalarico/pm/internal/commands" "github.com/gtalarico/pm/internal/config" ) func Run() { // Get all args, excluding binary name var args []string = os.Args[1:] cmdName, posArgs, err := cli.ValidateArgs(args) // No Args if err != nil { cli.ShowUsage() os.Exit(1) } // Checks for invalid command name // and number of args for a given command cmd, err := commands.GetCommand(cmdName) if err != nil { cli.Abort(err) } if cmd.NumArgs != len(posArgs) { cli.ShowCmdUsage(cmd.UsageMsg) } // Get Config config, err := config.ReadConfig() if err != nil { cli.Abort(err) } // Run Command cmd.Run(posArgs, config) }

Commands Module

Types, command functions and definitions

Command Type

// internal/commands/types.go package commands import "github.com/gtalarico/pm/internal/config" type Command struct { Name string NumArgs int UsageMsg string Run func([]string, config.Config) }

Commands Main

// internal/commands/commands.go package commands import ( "fmt" "os" "path/filepath" "github.com/gtalarico/pm/internal/cli" "github.com/gtalarico/pm/internal/config" "github.com/pkg/errors" ) func GetCommand(cmdName string) (command Command, err error) { for _, cmd := range Commands { if cmd.Name == cmdName { command = cmd return } } err = errors.New("invalid command") return } func CommandList(args []string, config config.Config) { cli.PrintProjects(config.Projects) } func CommandGo(args []string, cfg config.Config) { projectName := args[0] project, err := config.GetOneProject(projectName, cfg) if err != nil { cli.Abort(errors.Wrap(err, projectName)) } else { cli.Shell(project.Path) } } func CommandAdd(args []string, cfg config.Config) { projectName := args[0] projectPath := args[1] absPath, err := filepath.Abs(projectPath) if err != nil { cli.Abort(errors.Wrap(err, "invalid path")) } newProject := config.Project{ Name: projectName, Path: absPath, } projects := config.SearchProjects(projectName, cfg) if len(projects) == 0 { cfg.Projects = append(cfg.Projects, newProject) } else { for i, project := range cfg.Projects { if project.Name == newProject.Name { cfg.Projects[i] = newProject } } } writeError := config.WriteConfig(cfg) if writeError != nil { cli.Abort(writeError) } cli.PrintProjects(cfg.Projects) } func CommandRemove(args []string, cfg config.Config) { var projectToKeep []config.Project projectName := args[0] matchedProject, err := config.GetOneProject(projectName, cfg) if err != nil { cli.Abort(errors.Wrap(err, projectName)) } else { for _, project := range cfg.Projects { if project.Name != matchedProject.Name { projectToKeep = append(projectToKeep, project) } } cfg.Projects = projectToKeep promptMsg := fmt.Sprintf("Delete '%s' [Y/n]? ", matchedProject.Name) confirm := cli.ConfirmPrompt(promptMsg, true) if confirm == false { os.Exit(0) } writeError := config.WriteConfig(cfg) if writeError != nil { cli.Abort(writeError) } cli.PrintProjects(projectToKeep) } } var Commands = [...]Command{ Command{ Name: "list", NumArgs: 0, // pm list UsageMsg: "list", Run: CommandList, }, Command{ Name: "add", NumArgs: 2, // pm add <name> <path> UsageMsg: "add <project-name> <path>", Run: CommandAdd, }, Command{ Name: "remove", NumArgs: 1, // pm remove <query> UsageMsg: "remove <project-name>", Run: CommandRemove, }, Command{ Name: "go", NumArgs: 1, // pm go <project-name> UsageMsg: "go <project-name>", Run: CommandGo, }, }

Cli Module

Usage

// internal/commands/usage.go package cli import ( "fmt" "os" ) // Shows usage of all available commands and exits func ShowUsage() { usage := `Usage: pm list pm go <project-name> pm add <project-name> <project-path> pm remove <project-name>` fmt.Fprintln(os.Stderr, usage) } // Shows usage of a command and exits func ShowCmdUsage(usageMsg string) { fmt.Fprint(os.Stderr, fmt.Sprintf("Usage: pm %s", usageMsg)) os.Exit(1) }

Args

// internal/commands/args.go package cli import "github.com/pkg/errors" func ValidateArgs(args []string) (cmdName string, posArgs []string, err error) { if len(args) == 0 { err = errors.New("missing command name") return } if len(args) > 1 { posArgs = args[1:] } cmdName = args[0] return }

Output

// internal/cli/output.go // Poorly named file... package cli import ( "fmt" "log" "os" "strings" "github.com/gtalarico/pm/internal/config" ) func Abort(err error) { // Show error message and exit with error fmt.Fprint(os.Stderr, err.Error()) os.Exit(1) } // Prompts user to confirm a "y" or "n" and returns as boolean func ConfirmPrompt(promptMsg string, default_ bool) bool { fmt.Print(promptMsg) var response string _, err := fmt.Scanln(&response) if err != nil { log.Fatal(err) } if response == "" { return default_ } r := (strings.ToLower(response) == "y") return r } func PrintProjects(projects []config.Project) { for _, project := range projects { fmt.Println(project.Name) } }

Shell Handles New Shell Process Spawning

// internal/cli/shell.go package cli import ( "fmt" "os" "os/user" "github.com/pkg/errors" ) func handleShellError() { shellError := recover() if shellError != nil { err := errors.New("shell error") Abort(err) } } func Shell(cwd string) { //technosophos.com/2014/07/11/start-an-interactive-shell-from-within-go.html defer handleShellError() fmt.Println("Starting new shell") fmt.Println("Use 'CTRL + C' or '$ exit' to terminate child shell") // Get the current user. me, err := user.Current() if err != nil { panic(err) } // If needed: sets env vars // os.Setenv("SOME_VAR", "1") // Transfer stdin, stdout, and stderr to the new process // and also set target directory for the shell to start in. pa := os.ProcAttr{ Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}, Dir: cwd, } // Start up a new shell. // Note that we supply "login" twice. // -fpl means "don't prompt for PW and pass through environment." // fmt.Print(">> Starting a new interactive shell") proc, err := os.StartProcess("/usr/bin/login", []string{"login", "-fpl", me.Username}, &pa) if err != nil { panic(err) } // Wait until user exits the shell state, err := proc.Wait() if err != nil { panic(err) } fmt.Printf("Exited Go Sub Shell

%s

", state.String()) }

Config Module

Handles reading and writing of config file, including types for json encoding/decoding.

Types

// internal/config/types.go package config type Config struct { Projects []Project `json:"projects"` } type Project struct { Path string `json:"path"` Name string `json:"name"` }

Config - Main config functions

// internal/config/config.go package config import ( "encoding/json" "fmt" "io/ioutil" "os" "github.com/pkg/errors" ) const CFG_FILENAME = ".pm.json" func WriteConfig(config Config) (err error) { path := ConfigFilepath() configJson, _ := json.MarshalIndent(config, "", " ") writeErr := ioutil.WriteFile(path, configJson, 0644) err = errors.Wrap(writeErr, path) return } func CreateConfig(path string) (cfg Config, err error) { projects := []Project{} cfg = Config{projects} err = WriteConfig(cfg) return } func ReadConfig() (cfg Config, err error) { path := ConfigFilepath() configBytes, readErr := ioutil.ReadFile(path) if readErr != nil { // Try creating new file in case of read error (eg. file not found) cfg, err = CreateConfig(path) return } parsingError := json.Unmarshal(configBytes, &cfg) if parsingError != nil { err = errors.Wrap(parsingError, path) return } return } // Gets user home path using environment variable '$HOME' func UserHomePath() (path string) { path = os.Getenv("HOME") if path == "" { err, _ := fmt.Printf("Could not get home directory") panic(err) } return } // Gets the full config filepath func ConfigFilepath() string { return UserHomePath() + fmt.Sprintf("/%s", CFG_FILENAME) }

Search