At a high level, these areas make up the github.com/cli/cli
project:
cmd/
-main
packages for building binaries such as thegh
executablepkg/
- most other packages, including the implementation for individual gh commandsdocs/
- documentation for maintainers and contributorsscript/
- build and release scriptsinternal/
- Go packages highly specific to our needs and thus internalgo.mod
- external Go dependencies for this project, automatically fetched by Go at build time
Some auxiliary Go packages are at the top level of the project for historical reasons:
api/
- main utilities for making requests to the GitHub APIcontext/
- DEPRECATED: use only for referencing git remotesgit/
- utilities to gather information from a local git repositorytest/
- DEPRECATED: do not useutils/
- DEPRECATED: use only for printing table output
Running gh help issue list
displays help text for a topic. In this case, the topic is a specific command,
and help text for every command is embedded in that command's source code. The naming convention for gh
commands is:
pkg/cmd/<command>/<subcommand>/<subcommand>.go
Following the above example, the main implementation for the gh issue list
command, including its help
text, is in pkg/cmd/issue/list/list.go
Other help topics not specific to any command, for example gh help environment
, are found in
pkg/cmd/root/help_topic.go.
During our release process, these help topics are automatically converted to manual pages and published under https://cli.github.com/manual/.
To illustrate how GitHub CLI works in its typical mode of operation, let's build the project, run a command, and talk through which code gets run in order.
go run script/build.go
- Makes sure all external Go dependencies are fetched, then compiles thecmd/gh/main.go
file into abin/gh
binary.bin/gh issue list --limit 5
- Runs the newly builtbin/gh
binary (note: on Windows you must use backslashes likebin\gh
) and passes the following arguments to the process:["issue", "list", "--limit", "5"]
.func main()
insidecmd/gh/main.go
is the first Go function that runs. The arguments passed to the process are available throughos.Args
.- The
main
package initializes the "root" command withroot.NewCmdRoot()
and dispatches execution to it withrootCmd.ExecuteC()
. - The root command represents the top-level
gh
command and knows how to dispatch execution to any other gh command nested under it. - Based on
["issue", "list"]
arguments, the execution reaches theRunE
block of thecobra.Command
within pkg/cmd/issue/list/list.go. - The
--limit 5
flag originally passed as arguments be automatically parsed and its value stored asopts.LimitResults
. func listRun()
is called, which is responsible for implementing the logic of thegh issue list
command.- The command collects information from sources like the GitHub API then writes the final output to
standard output and standard error streams available at
opts.IO
. - The program execution is now back at
func main()
ofcmd/gh/main.go
. If there were any Go errors as a result of processing the command, the function will abort the process with a non-zero exit status. Otherwise, the process ends with status 0 indicating success.
- First, check on our issue tracker to verify that our team had approved the plans for a new command.
- Create a package for the new command, e.g. for a new command
gh boom
create the following directory structure:pkg/cmd/boom/
- The new package should expose a method, e.g.
NewCmdBoom()
, that accepts a*cmdutil.Factory
type and returns a*cobra.Command
.- Any logic specific to this command should be kept within the command's package and not added to any
"global" packages like
api
orutils
.
- Any logic specific to this command should be kept within the command's package and not added to any
"global" packages like
- Use the method from the previous step to generate the command and add it to the command tree, typically
somewhere in the
NewCmdRoot()
method.
This task might be tricky. Typically, gh commands do things like look up information from the git repository
in the current directory, query the GitHub API, scan the user's ~/.ssh/config
file, clone or fetch git
repositories, etc. Naturally, none of these things should ever happen for real when running tests, unless
you are sure that any filesystem operations are strictly scoped to a location made for and maintained by the
test itself. To avoid actually running things like making real API requests or shelling out to git
commands, we stub them. You should look at how that's done within some existing tests.
To make your code testable, write small, isolated pieces of functionality that are designed to be composed together. Prefer table-driven tests for maintaining variations of different test inputs and expectations when exercising a single piece of functionality.