TLDR
Python developer; first project in Go; looking for feedback :)
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\n %s\n", 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
// internal/config/search.go
package config
import (
"errors"
"fmt"
"strings"
)
func SearchProjects(query string, config Config) []Project {
var projects []Project
for _, project := range config.Projects {
if strings.Contains(project.Name, query) {
projects = append(projects, project)
}
}
return projects
}
func GetOneProject(query string, config Config) (project Project, err error) {
projects := SearchProjects(query, config)
numProjects := len(projects)
if numProjects == 1 {
project = projects[0]
}
if numProjects == 0 {
err = errors.New("no match")
}
if numProjects > 1 {
for _, project := range projects {
fmt.Println(project.Name)
}
err = errors.New("multiple matches")
}
return
}