Two mistakes in the size support, reported from a real server. The remote command used "2>/dev/null" to silence du. That is sh syntax, and the git user's login shell need not be sh: in csh it parses as an argument "2" followed by a redirection of stdout, so du was handed a file named "2", complained, and exited non-zero. The redirection is gone -- without it there is no bogus argument to trip over, and the command now uses nothing that differs between sh and csh. Worse, the exit status of the chain is the *last* command's, so that failing du made sshOut return an error and `list` threw away a listing that had arrived perfectly intact. It now reports a failure only when nothing usable came back at all; a listing that parsed is shown whatever the exit status, simply without the size column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
854 lines
24 KiB
Go
854 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. Nothing shell-specific here on purpose: the
|
|
// login shell may be csh, where "2>/dev/null" is not a redirection
|
|
// but an argument followed by one.
|
|
remote += "; echo " + shq(listMarker) + "; du -sk *.git"
|
|
}
|
|
lines, err := sshOut(remote)
|
|
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)
|
|
}
|
|
// The exit status belongs to the last command in the chain, so a `du`
|
|
// that fails must not discard a listing that arrived intact. Only
|
|
// complain when nothing usable came back at all.
|
|
if len(entries) == 0 {
|
|
if err != nil {
|
|
errorln("could not list " + many + " on the git server")
|
|
break
|
|
}
|
|
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()
|
|
}
|