Files
mgsh/commands.go
T
mikeandClaude Opus 5 3a420093d1 Show repository sizes in list
`list -a` had sizes because archives are files; repositories are
directories, and a long listing reports the inode size for those -- 4096
for every single one. Taking that number would have filled the column
with the same meaningless value, so the real disk usage is asked of `du`
instead, appended to the same remote command so it still costs one round
trip.

The column is dropped entirely when no usable sizes come back, rather
than showing a column of zeroes, so a server without a working `du`
degrades to the previous output. The summary line carries the total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:30:12 +02:00

849 lines
24 KiB
Go

package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
var (
optRe = regexp.MustCompile(`^-(\w)$`)
numRe = regexp.MustCompile(`^\d+$`)
// a `ls -ltr` long-listing line: mode, link count, owner, group, size, the
// three date columns, then the name. Owner and group are matched as opaque
// fields — the bare repositories need not belong to a user or group
// literally named "git".
lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$`)
gitDirRe = regexp.MustCompile(`^(.*)\.git$`)
sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`)
wsRe = regexp.MustCompile(`\s+`)
)
// listMarker separates the two sections of the combined listing command, so
// `list` gets both the long listing and the disk usage in one round trip.
const listMarker = "---mgsh---"
// duRe matches one `du -sk` line: kilobytes, then the path.
var duRe = regexp.MustCompile(`^(\d+)\s+(.*)$`)
// splitAtMarker divides the remote output into the part before and after the
// marker line. Everything is in the first section when the marker is absent —
// which is what happens when only a plain listing was asked for.
func splitAtMarker(lines []string, marker string) (before, after []string) {
for i, ln := range lines {
if strings.TrimSpace(ln) == marker {
return lines[:i], lines[i+1:]
}
}
return lines, nil
}
// parseDuSizes turns `du -sk` output into a name -> bytes map. A long listing
// reports the inode size for a directory — the same number for every bare
// repository — so this is the only way to say how large one actually is.
// A symlinked repository reports the size of the link, not of its target.
func parseDuSizes(lines []string) map[string]int64 {
out := map[string]int64{}
for _, ln := range lines {
m := duRe.FindStringSubmatch(strings.TrimRight(ln, "\r"))
if m == nil {
continue
}
kb, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
continue
}
out[strings.TrimPrefix(strings.TrimSpace(m[2]), "./")] = kb * 1024
}
return out
}
// lsEntry is one parsed entry of the server's listing.
type lsEntry struct {
name string // with the ".git" / ".git.tar.gz" suffix removed
date string // the ls date columns, normalised to a fixed 12 columns
size int64
}
// parseLsEntry reads one `ls -ltr` line whose entry name ends in suffix. It
// returns false for anything else: the leading "total" line, entries of another
// kind, or output that does not look like a long listing at all.
func parseLsEntry(line, suffix string) (lsEntry, bool) {
m := lsEntryRe.FindStringSubmatch(strings.TrimSpace(line))
if m == nil {
return lsEntry{}, false
}
name := m[5]
if i := strings.Index(name, " -> "); i >= 0 {
name = name[:i] // a symlinked bare repo lists as "link.git -> target.git"
}
if !strings.HasSuffix(name, suffix) {
return lsEntry{}, false
}
size, _ := strconv.ParseInt(m[1], 10, 64)
return lsEntry{
name: strings.TrimSuffix(name, suffix),
// ls pads these itself, but only in its own column widths; re-pad so
// "Sep 28 2016" and "Jan 3 14:32" line up at 12 either way
date: fmt.Sprintf("%s %2s %5s", m[2], m[3], m[4]),
size: size,
}, true
}
// validProject reports whether name is usable as a project name: a single path
// element directly under BASE. Names containing a separator would escape BASE
// and turn the remote "<name>.git" paths into something else entirely.
func validProject(name string) bool {
return name != "" && !strings.HasPrefix(name, ".") &&
!strings.ContainsAny(name, "/\\")
}
// requireProject checks that a usable project is active, printing an error
// otherwise. Returns true when it is safe to proceed.
func requireProject() bool {
if PRJ == "" {
errorln("no project selected")
return false
}
if !validProject(PRJ) {
errorln("invalid project name: " + PRJ)
return false
}
return true
}
// requireRepo checks that a project with a .git is active, printing an error
// otherwise. Returns true when it is safe to proceed.
func requireRepo() bool {
if !requireProject() {
return false
}
if !isDir(DIR + "/.git") {
errorln("error, no repository (.git) found")
return false
}
return true
}
// runCommand parses one command line and dispatches it. It returns false when
// the shell should terminate (quit/exit).
func runCommand(line string) bool {
return runCommandDepth(line, 0)
}
// runCommandDepth is runCommand with an alias-expansion recursion counter.
func runCommandDepth(line string, depth int) bool {
line = strings.TrimSpace(line)
// A leading '!' runs the rest of the line as a shell command. Nothing else
// is forwarded to the shell any more.
if strings.HasPrefix(line, "!") {
if sh := strings.TrimSpace(line[1:]); sh != "" {
forwardShell(sh)
}
return true
}
// Expand a user-defined alias in the first word, then re-dispatch. The
// depth guard prevents runaway/cyclic expansion.
if depth < maxAliasDepth {
if f := strings.Fields(line); len(f) > 0 {
if body, ok := aliases[f[0]]; ok && !isBuiltin(f[0]) {
return runCommandDepth(expandAlias(body, f[1:]), depth+1)
}
}
}
fields := strings.Fields(line)
// separate `-x` option flags from positional words.
var words []string
opt := map[string]bool{}
for _, f := range fields {
if m := optRe.FindStringSubmatch(f); m != nil {
opt[m[1]] = true
} else {
words = append(words, f)
}
}
switch word(words, 0) {
case "": // empty line or options only — nothing to do
case "quit", "exit":
return false
case "help":
help()
case "alias": // list, show or define a command alias (persisted)
handleAlias(line)
case "unalias": // remove a command alias
handleUnalias(word(words, 1))
case "config": // show the effective configuration and where it came from
if opt["k"] {
fmt.Println(strings.Join(configKeys(), "\n"))
fmt.Println("remote.<name>.url|key|type|visibility")
break
}
showConfig()
case "rescan": // reload the configuration and the cached server repo list
reloadConfig()
rescanServer()
fmt.Println("configuration and server repository list refreshed")
case "dist": // cp changed files to another directory/repository
if !requireRepo() { // `git ls-files` below would otherwise walk BASE
break
}
ddir := BASE + "/dist/" + PRJ
if w := word(words, 1); w != "" {
ddir = w
}
if !isDir(ddir) {
errorln(fmt.Sprintf("dist path not found (%s)", ddir))
break
}
n := 0
out, _ := gitCapture(DIR, "ls-files")
for _, f := range splitLines(out) {
if f == "" || strings.HasPrefix(f, "private") {
continue
}
src := filepath.Join(DIR, f)
dst := filepath.Join(ddir, f)
if !filesEqual(src, dst) {
fmt.Println(f)
if err := copyFile(src, dst); err != nil {
fmt.Fprintln(os.Stderr, col(cRed, fmt.Sprintf("copy %s: %v", f, err)))
continue
}
n++
}
}
fmt.Printf("%d files copied to %s\n", n, ddir)
case "list": // list repositories on the git server
path, suffix := ".", ".git"
if opt["a"] {
path, suffix = "./archive", ".git.tar.gz"
}
one, many := "repository", "repositories"
if opt["a"] {
one, many = "archive", "archives"
}
pat := strings.ToLower(word(words, 1))
remote := "/bin/ls -ltr " + shq(path)
if !opt["a"] {
// archives are files and carry a real size; repositories are
// directories, whose listed size is the inode's, so ask du in the
// same round trip
remote += "; echo " + shq(listMarker) + "; du -sk *.git 2>/dev/null"
}
lines, err := sshOut(remote)
if err != nil {
errorln("could not list " + many + " on the git server")
break
}
lsLines, duLines := splitAtMarker(lines, listMarker)
sizes := parseDuSizes(duLines)
var entries []lsEntry
var total int64
for _, ln := range lsLines {
e, ok := parseLsEntry(ln, suffix)
// the pattern filters the name, not the whole listing line — an
// accidental match on the date or the owner helps nobody
if !ok || (pat != "" && !strings.Contains(strings.ToLower(e.name), pat)) {
continue
}
if !opt["a"] {
e.size = sizes[e.name+suffix] // 0 when du said nothing
}
total += e.size
entries = append(entries, e)
}
if len(entries) == 0 {
what := "no " + many + " on the git server"
if pat != "" {
what = "no " + many + " matching '" + word(words, 1) + "'"
}
fmt.Println(col(cGray, what))
break
}
// no size column when the server gave no usable sizes, rather than a
// column of zeroes
fmt.Print(formatRepoList(entries, total > 0))
label := many
if len(entries) == 1 {
label = one
}
summary := fmt.Sprintf("%d %s", len(entries), label)
if total > 0 {
summary += " · " + humanSize(total)
}
fmt.Println(col(cGray, summary))
case "show": // show a repository's log directly on the server
prj := PRJ
if w := word(words, 1); w != "" {
prj = w
}
if !validProject(prj) {
errorln("usage: show <repository>")
break
}
exists, err := serverEntryExists(".", prj+".git")
if err != nil {
errorln("could not reach the git server")
break
}
if !exists {
errorln("repository not found")
break
}
logLines, _ := sshOut("cd " + shq(cfg.GitPath+"/"+prj+".git") +
" && git log --reverse --format='%h %ct %s'")
repolog(logLines)
case "log":
if !requireRepo() {
break
}
out, _ := gitCapture(DIR, "log", "--reverse", "--format=%h %ct %s")
repolog(splitLines(out))
case "status": // short git status of the active project (-a: all projects)
if opt["a"] {
overviewAll()
break
}
if !requireRepo() {
break
}
git(DIR, "status", "-sb")
case "overview": // status summary of all projects under BASE
overviewAll()
case "diff": // git diff of the active project (with optional args)
if !requireRepo() {
break
}
git(DIR, append([]string{"diff"}, fields[1:]...)...)
case "pull":
if !requireRepo() {
break
}
gitOK(DIR, "pull")
case "fetch":
if !requireRepo() {
break
}
gitOK(DIR, "fetch")
case "push": // commit everything and push to the server
if !requireProject() {
break
}
if fileExists(DIR + "/push.pl") {
runInDir(DIR, "perl", DIR+"/push.pl")
}
if !isDir(DIR + "/.git") {
errorln("error, no repository (.git) found")
break
}
comment := strings.Join(fields[1:], " ")
git(DIR, "add", "--all", ".")
// last look before anything is committed: `add --all` sweeps up whatever
// is lying around, and with mirroring on it goes straight to a public
// server. Nothing has been committed yet, so declining costs nothing.
if !secretsApproved(DIR) {
errorln("push cancelled — your changes are staged but not committed")
break
}
msg := strings.TrimSpace(fmt.Sprintf("[%s@%s] %s", USER, HOST, comment))
git(DIR, "commit", "-m", msg) // may be "nothing to commit"; continue anyway
if !gitOK(DIR, "push") {
break
}
sshOK("touch " + shq(cfg.GitPath+"/"+PRJ+".git"))
if targets, _ := cfg.mirrorTargets(); truthy(cfg.Mirror) && len(targets) > 0 {
handlePushRemote("") // auto-mirror to every configured server
}
case "pushremote": // mirror the repo to a public git server via its API
handlePushRemote(strings.Join(fields[1:], " "))
case "release": // tag a commit and publish it as a release on the mirrors
handleRelease(strings.Join(fields[1:], " "))
case "edit": // interactively edit the last N commits
if !requireRepo() {
break
}
num := "10"
if w := word(words, 1); numRe.MatchString(w) {
num = w
}
if !gitOK(DIR, "rebase", "-i", "HEAD~"+num) {
break
}
if yesno("force-push rewritten history?", false) {
gitOK(DIR, "push", "--force")
}
case "archive": // snapshot the server-side repository into ./archive
if !requireProject() {
break
}
// like `init`/`clone`/`show`, ask the server before acting on it —
// otherwise a missing repository surfaces as a raw `cp -r` failure
exists, err := serverEntryExists(".", PRJ+".git")
if err != nil {
errorln("could not reach the git server: " + err.Error())
break
}
if !exists {
errorln("no repository " + PRJ + " on the git server — run 'init' first")
break
}
comment := sanitizeComment(strings.Join(fields[1:], " "))
z := archiveStamp()
name := PRJ + "_" + z
if comment != "" {
name = PRJ + "_" + z + "_" + comment
}
if !sshOK("cp -r " + shq(PRJ+".git") + " " + shq("archive/"+name+".git")) {
break
}
if !sshOK("cd archive && tar cvzf " + shq(name+".git.tar.gz") + " " + shq(name+".git")) {
break
}
sshOK("rm -rf " + shq("archive/"+name+".git"))
case "init": // create a new repository from the current directory
if !requireProject() {
break
}
if fileExists(DIR + "/push.pl") {
runInDir(DIR, "perl", DIR+"/push.pl")
}
// Whether a repository would be destroyed is a property of the *server*,
// not of this checkout's remote.origin.url — an unlinked project
// directory says nothing about what is on the other end.
exists, err := serverEntryExists(".", PRJ+".git")
if err != nil {
errorln("could not reach the git server: " + err.Error())
break
}
if exists && !yesno("overwrite existing repository "+PRJ+" on the server?", false) {
break
}
remote := shq(cfg.GitPath + "/" + PRJ + ".git")
if !sshOK("rm -rf " + remote) {
break
}
if !sshOK("mkdir " + remote + " && cd " + remote + " && git --bare init") {
break
}
gi := DIR + "/.gitignore"
if !fileExists(gi) || yesno("overwrite existing .gitignore?", false) {
if err := os.WriteFile(gi, []byte(gitignore), 0644); err != nil {
errorln("could not write " + gi + ": " + err.Error())
}
}
os.RemoveAll(DIR + "/.git")
if !gitOK(DIR, "init") {
break
}
if !gitOK(DIR, "remote", "add", "origin", URL+"/"+PRJ+".git/") {
break
}
if !gitOK(DIR, "add", ".") {
break
}
if !gitOK(DIR, "commit", "-m", fmt.Sprintf("initial commit [%s,%s]", resolveIP(), USER)) {
break
}
gitOK(DIR, "push", "-u", "origin", "master")
case "login": // open an interactive ssh session to the server
runInDir("", "ssh", sshArgs(cfg.GitUser+"@"+cfg.GitHost)...)
case "cd": // change the current project
arg := word(words, 1)
if strings.HasPrefix(arg, ".") {
break // ignore filesystem-relative navigation
}
PRJ = "" // bare `cd` deselects the project and returns to BASE
if validProject(arg) && isDir(BASE+"/"+arg) {
PRJ = arg
}
case "checkout":
if !requireRepo() {
break
}
if len(fields) < 2 {
errorln("usage: checkout <branch|tag|git options>")
break
}
// forward the raw fields, not the option-stripped words: `checkout -b
// topic` has to reach git with its flag intact.
gitOK(DIR, append([]string{"checkout"}, fields[1:]...)...)
case "clone": // clone a repository (or archive with -a) from the server
prj := PRJ
if w := word(words, 1); w != "" {
prj = w
}
if !validProject(prj) {
errorln("invalid repository name: " + prj)
break
}
path, entry := ".", prj+".git"
if opt["a"] {
path, entry = "./archive", prj+".git.tar.gz"
}
exists, err := serverEntryExists(path, entry)
if err != nil {
errorln("could not reach the git server: " + err.Error())
break
}
if !exists {
errorln("repository not found, try 'list [-a]'")
break
}
if isDir(BASE + "/" + prj) {
if yesno("overwrite existing directory?", false) {
os.RemoveAll(BASE + "/" + prj)
} else {
break
}
}
if !opt["a"] {
if !gitOK(BASE, "clone", URL+"/"+prj+".git") {
break
}
} else {
if !sshOK("cd archive && tar xvzf " + shq(prj+".git.tar.gz")) {
break
}
if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") {
break
}
sshOK("rm -rf " + shq("archive/"+prj+".git"))
}
if isDir(BASE + "/" + prj) {
PRJ = prj
}
case "cloneall": // clone every repository found on the server
path := "."
if opt["a"] {
path = "./archive"
}
lines, err := sshOut("/bin/ls " + shq(path))
if err != nil {
errorln("could not list repositories on the git server")
break
}
for _, ln := range lines {
t := strings.TrimSpace(ln)
if m := gitDirRe.FindStringSubmatch(t); m != nil {
fmt.Printf("-%s-\n", m[1])
gitOK(BASE, "clone", URL+"/"+m[1]+".git")
}
}
case "open", "view": // open project in Xcode / editor
prj := PRJ
if w := word(words, 1); w != "" {
prj = w
}
d := BASE + "/" + prj
if !validProject(prj) || !isDir(d) {
errorln("not found")
break
}
xws, xprj := "", ""
if entries, err := os.ReadDir(d); err == nil {
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".xcworkspace") {
xws = e.Name()
}
if strings.HasSuffix(e.Name(), ".xcodeproj") {
xprj = e.Name()
}
}
}
// prefer the workspace over the project; fall back to the editor unless
// one of them is really openable (a name match on a plain file is not).
switch {
case xws != "" && isDir(d+"/"+xws):
runInDir(d, "open", xws)
case xprj != "" && isDir(d+"/"+xprj):
runInDir(d, "open", xprj)
default:
editor := cfg.Editor
if editor == "" {
editor = "coda"
}
runInDir(d, editor, d)
}
if words[0] == "open" {
PRJ = prj
}
case "count": // count source lines in the project
if !requireProject() {
break
}
countLines(DIR)
case "tag": // manage tags
if !requireRepo() {
break
}
sub := word(words, 1)
switch {
case sub == "add" && word(words, 2) != "":
// `tag add v1.0 why this exists` annotates with that text; without
// one the tag name is the message, as before. The annotation is
// what `release` falls back to for its notes.
msg := words[2]
if len(fields) > 3 {
msg = strings.Join(fields[3:], " ")
}
if gitOK(DIR, "tag", "-a", words[2], "-m", msg) {
gitOK(DIR, "push", "origin", words[2])
}
case sub == "checkout" && word(words, 2) != "":
gitOK(DIR, "checkout", "tags/"+words[2])
case sub == "delete" && word(words, 2) != "":
if gitOK(DIR, "tag", "-d", words[2]) {
gitOK(DIR, "push", "origin", ":refs/tags/"+words[2])
}
case sub != "":
errorln("unkown tag subcommand")
default:
git(DIR, "tag", "-l", "--format=%(taggerdate:short): %(refname:short)")
}
default: // unknown command — no longer forwarded to the shell
fmt.Println(col(cRed, "unknown command: "+words[0]) +
col(cGray, " (prefix with '!' to run a shell command)"))
}
return true
}
// ---------------------------------------------------------------------------
// repository log formatting
// ---------------------------------------------------------------------------
// repolog prints the formatted log for "hash<2sp>epoch<2sp>subject" lines.
func repolog(lines []string) {
fmt.Print(formatLog(lines, time.Now()))
}
// formatLog renders the log, picking the most compact date column that still
// disambiguates every entry. Returned as a string for testability.
func formatLog(lines []string, now time.Time) string {
days := [...]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
months := [...]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
var full, short, tiny strings.Builder
n, ns := 0, 0
for _, ln := range lines {
if strings.TrimSpace(ln) == "" {
continue
}
parts := strings.SplitN(ln, " ", 3)
if len(parts) < 3 {
continue
}
hash := parts[0]
epoch, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if err != nil {
continue
}
subj := parts[2]
t := time.Unix(epoch, 0)
age := now.Sub(t)
wd := int(t.Weekday())
var z, zs, zss string
switch {
case age < 24*time.Hour:
z = fmt.Sprintf(" %02d:%02d", t.Hour(), t.Minute())
zs = fmt.Sprintf(" %02d:%02d", t.Hour(), t.Minute())
zss = fmt.Sprintf("%02d:%02d", t.Hour(), t.Minute())
case age < 7*24*time.Hour:
z = fmt.Sprintf(" %s, %02d:%02d", days[wd], t.Hour(), t.Minute())
zs = fmt.Sprintf("%s, %02d:%02d", days[wd], t.Hour(), t.Minute())
zss = zs
ns++
default:
z = fmt.Sprintf("%s, %02d %s %4d, %02d:%02d",
days[wd], t.Day(), months[t.Month()-1], t.Year(), t.Hour(), t.Minute())
zs, zss = z, z
n++
}
fmt.Fprintf(&full, "%s %s %s\n", col(cPurple, hash), col(cYellow, z), subj)
fmt.Fprintf(&short, "%s %s %s\n", col(cPurple, hash), col(cYellow, zs), subj)
fmt.Fprintf(&tiny, "%s %s %s\n", col(cPurple, hash), col(cYellow, zss), subj)
}
switch {
case n == 0 && ns == 0:
return tiny.String()
case n == 0 && ns > 0:
return short.String()
default:
return full.String()
}
}
// ---------------------------------------------------------------------------
// line counting
// ---------------------------------------------------------------------------
// countLines prints the line total over all source files in the project.
func countLines(dir string) {
total, files := countSourceLines(dir)
fmt.Printf("%d lines total in %d files in %s\n", total, files, PRJ)
}
// countSourceLines walks dir and sums the lines of every source file, returning
// the line and file totals. A "source file" is any text (non-binary) file;
// hidden directories (.git, …) and hidden files are skipped.
func countSourceLines(dir string) (lines, files int) {
filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return nil
}
if fi.IsDir() {
if p != dir && strings.HasPrefix(fi.Name(), ".") {
return filepath.SkipDir
}
return nil
}
if strings.HasPrefix(fi.Name(), ".") || !isTextFile(p) {
return nil
}
lines += countFileLines(p)
files++
return nil
})
return lines, files
}
func countFileLines(path string) int {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
return bytes.Count(data, []byte{'\n'})
}
func isTextFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
buf := make([]byte, 512)
n, _ := f.Read(buf)
return !bytes.Contains(buf[:n], []byte{0})
}
// ---------------------------------------------------------------------------
// misc command helpers
// ---------------------------------------------------------------------------
func sanitizeComment(s string) string {
s = sanRe.ReplaceAllString(s, " ")
s = strings.TrimSpace(s)
s = wsRe.ReplaceAllString(s, "_")
return s
}
// archiveStamp builds the DDMMYY.HHMM timestamp used for archive names.
func archiveStamp() string {
t := time.Now()
return fmt.Sprintf("%02d%02d%02d.%02d%02d",
t.Day(), int(t.Month()), t.Year()-2000, t.Hour(), t.Minute())
}
const gitignore = `.DS_Store
.AppleDouble
.LSOverride
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
`
var helpItems = []struct{ cmd, desc string }{
{"cd [project]", "change project (no argument: back to the base)"},
{"open [project]", "open project"},
{"init", "make new repository from current directory"},
{"push [comment]", "push changes to git server"},
{"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"},
{"release <tag> [notes]", "tag and publish a release on the public server(s)"},
{"pull", "pull changes from git server"},
{"fetch", "fetch changes from git server"},
{"status [-a]", "short git status (-a: overview of all projects)"},
{"overview", "inventory of all projects, local and on the server"},
{"diff [args]", "show git diff"},
{"edit [number]", "edit last [number] commits (default is 10)"},
{"clone [-a] <repository>", "clone repository from git server (-a for archive)"},
{"cloneall", "clone all repository from git server"},
{"checkout [git options]", "forward checkout to git"},
{"log", "show log"},
{"archive [comment]", "archive current repository"},
{"list [-a] [pattern]", "list repositories on git server (-a for archive)"},
{"show <repository>", "show repository log on git server"},
{"dist <path>", "cp changed files to other directory/repository"},
{"login", "connect to git server"},
{"count", "count lines in project"},
{"tag", "show tags"},
{"tag add <tag> [msg]", "add tag (msg becomes the annotation)"},
{"tag checkout <tag>", "checkout tag"},
{"tag delete <tag>", "delete tag"},
{"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"},
{"unalias <name>", "remove an alias"},
{"config [-k]", "show effective configuration (-k: list all setting names)"},
{"rescan", "reload config and refresh cached server repository list"},
{"!<command>", "run <command> in the shell"},
{"quit", "exit mgsh"},
}
func help() {
fmt.Println()
fmt.Printf("%s v%s %s, builtin commands:\n\n",
col(cBold+cWhite, "mgsh (git shell)"), col(cYellow, VERSION), INFO)
for _, it := range helpItems {
fmt.Printf(" %s%s\n", col(cGreen, padRight(it.cmd, 27)), it.desc)
}
fmt.Println()
}