`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>
849 lines
24 KiB
Go
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()
|
|
}
|