Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 55 additions & 18 deletions linux/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ FLAGS (for add)
Default: "gitwatchd auto-commit (%d)".
-d <fmt> Format string for that timestamp (see ` + "`man date`" + `).
Default: "+%Y-%m-%d %H:%M:%S".
-l <lines> Use the diff itself as the commit message (file:line: change,
in colour), up to <lines> lines (0 = no limit). A diff
longer than <lines> falls back to the ` + "`git diff --stat`" + `
summary. Overrides -m.
-L <lines> Same as -l, without colour. Known bug: on git versions > 2.39
this falls back to a status summary.
-c <command> Run <command> and use its output as the commit message.
Overrides -m and -d.
-C Pipe the changed file names into the -c command's stdin.
Requires -c flag, e.g. ` + "`gitwatchd -c 'xargs echo updated:' -C .`" + `
-x <pattern> Skip changes whose path matches this regular
expression (e.g. '\.log$' or 'build/').
-M Skip committing while the repo has a merge in progress.
Expand All @@ -84,24 +94,29 @@ type ConfigError struct {
// A watched-repo specification, expressed in gitwatch's flag vocabulary.
//
// gitwatch [-s secs] [-d fmt] [-r remote [-b branch]] [-R] [-m msg]
// [-x pattern] [-M] [-g gitdir] [-e events] <target>
// [-c cmd] [-C] [-l|-L lines] [-x pattern] [-M] [-g gitdir]
// [-e events] <target>
//
// Each config line is exactly the argument string you'd pass to gitwatch (modulo path resolution).
type RepoSpec struct {
Path string
Settle float64 // -s debounce seconds
DateFormat string // -d
Remote string // -r
Branch string // -b
Rebase bool // -R pull --rebase before push
Message string // -m (%d -> date)
Exclude string // -x regex; last one wins, as upstream
NoMergeCommit bool // -M
CommitOnStart bool // -f commit pending changes when watching starts
GitDir string // -g --git-dir
Paused bool // --paused (gitwatchd extension, not gitwatch)
Raw string // the config line this spec came from, for change detection
targetIndex int // which arg was the target, so add can persist it resolved
Path string
Settle float64 // -s debounce seconds
DateFormat string // -d
Remote string // -r
Branch string // -b
Rebase bool // -R pull --rebase before push
Message string // -m (%d -> date)
CommitCommand string // -c its stdout becomes the commit message
PipeChangedFiles bool // -C pipe changed file names to the -c command
ListChanges int // -l/-L diff lines allowed in the message; -1 off, 0 unlimited
ListChangesColor bool // false once -L appears; -l never restores it, as upstream
Exclude string // -x regex; last one wins, as upstream
NoMergeCommit bool // -M
CommitOnStart bool // -f commit pending changes when watching starts
GitDir string // -g --git-dir
Paused bool // --paused (gitwatchd extension, not gitwatch)
Raw string // the config line this spec came from, for change detection
targetIndex int // which arg was the target, so add can persist it resolved
}

func (s RepoSpec) Name() string {
Expand Down Expand Up @@ -764,9 +779,11 @@ func tokenize(line string) []string {
// Returns nil + an error message if there's no valid target.
func parseRepoSpec(args []string) (*RepoSpec, string) {
spec := &RepoSpec{
Settle: 2,
DateFormat: "+%Y-%m-%d %H:%M:%S",
Message: "gitwatchd auto-commit (%d)",
Settle: 2,
DateFormat: "+%Y-%m-%d %H:%M:%S",
Message: "gitwatchd auto-commit (%d)",
ListChanges: -1,
ListChangesColor: true,
}
target := ""
haveTarget := false
Expand Down Expand Up @@ -807,6 +824,24 @@ func parseRepoSpec(args []string) (*RepoSpec, string) {
if v, ok := next(); ok {
spec.Message = v
}
case "-c":
if v, ok := next(); ok {
spec.CommitCommand = v
}
case "-C":
// Boolean, as on macOS: upstream declares `C:` yet ignores OPTARG,
// so its documented `-c cmd -C <target>` swallows the target.
spec.PipeChangedFiles = true
case "-l", "-L":
v, ok := next()
n, err := strconv.Atoi(v)
if !ok || err != nil || n < 0 {
return nil, a + " needs a number of lines, 0 or more"
}
spec.ListChanges = n
if a == "-L" {
spec.ListChangesColor = false // -l never restores colour, as upstream
}
case "-x":
v, ok := next()
if !ok {
Expand All @@ -826,6 +861,8 @@ func parseRepoSpec(args []string) (*RepoSpec, string) {
}
case "-e":
next() // -e accepted for gitwatch compatibility; ignored
case "-v":
// verbose: accepted, no-op (gitwatch's -v turns on bash tracing)
case "--paused":
spec.Paused = true
default:
Expand Down
64 changes: 63 additions & 1 deletion linux/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,12 @@ func TestDefaultsForBarePath(t *testing.T) {
if spec.CommitOnStart {
t.Error("-f is opt-in: a deliberate manual state is not flushed")
}
if spec.CommitCommand != "" || spec.PipeChangedFiles { // -c "Overrides -m and -d"; -C off
t.Errorf("unexpected non-default: %+v", spec)
}
if spec.ListChanges != -1 || !spec.ListChangesColor {
t.Error("-l/-L off by default; the -m message is used")
}
}

func TestPIsAnAliasOfR(t *testing.T) {
Expand All @@ -430,6 +436,61 @@ func TestSettleRejectsBadValues(t *testing.T) {
}
}

func TestVerboseIsAcceptedAndIgnored(t *testing.T) {
spec, msg := parseRepoSpec([]string{"-v", "/tmp/x"})
if spec == nil || spec.Path != "/tmp/x" {
t.Errorf("-v is accepted for gitwatch parity, then ignored: %s", msg)
}
}

func TestListChangesFlagsSetTheCapAndColour(t *testing.T) {
spec, _ := parseRepoSpec([]string{"-l", "10", "/tmp/x"})
if spec.ListChanges != 10 || !spec.ListChangesColor {
t.Errorf("got %+v", spec)
}
spec, _ = parseRepoSpec([]string{"-L", "10", "/tmp/x"})
if spec.ListChanges != 10 || spec.ListChangesColor {
t.Errorf("-L is -l without colour, got %+v", spec)
}
spec, _ = parseRepoSpec([]string{"-L", "5", "-l", "3", "/tmp/x"})
if spec.ListChanges != 3 {
t.Errorf("the line cap itself is last-wins, got %+v", spec)
}
if spec.ListChangesColor {
t.Error("upstream's -l never restores colour once -L appeared")
}
}

func TestListChangesRejectsBadLineCounts(t *testing.T) {
if spec, _ := parseRepoSpec([]string{"-l", "many", "/tmp/x"}); spec != nil {
t.Error("-l many should be rejected")
}
if spec, _ := parseRepoSpec([]string{"-L", "-1", "/tmp/x"}); spec != nil {
t.Error("-L -1 should be rejected")
}
if spec, _ := parseRepoSpec([]string{"-l", "0", "/tmp/x"}); spec == nil || spec.ListChanges != 0 {
t.Error("-l 0 is valid and means unlimited")
}
}

func TestCommitCommandIsStoredVerbatim(t *testing.T) {
spec, _ := parseRepoSpec([]string{"-c", "echo a b", "-C", "/tmp/x"})
if spec == nil || spec.CommitCommand != "echo a b" || !spec.PipeChangedFiles {
t.Errorf("got %+v", spec)
}
}

// Deliberate divergence from upstream, matching macOS: gitwatch's getopts
// declares -C as taking an argument, so there `-c cmd -C <target>` loses the
// target. Treating -C as the boolean its body implies keeps every documented
// invocation working.
func TestPipeFlagConsumesNoArgument(t *testing.T) {
spec, _ := parseRepoSpec([]string{"-C", "-r", "origin", "/tmp/x"})
if spec == nil || !spec.PipeChangedFiles || spec.Remote != "origin" || spec.Path != "/tmp/x" {
t.Errorf("got %+v", spec)
}
}

func TestExcludeMatchesAnywhereInPath(t *testing.T) {
spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "/tmp/x"})
if !spec.Excludes("/tmp/x/deep/dir/debug.log") {
Expand Down Expand Up @@ -480,9 +541,10 @@ func TestNoExcludeByDefault(t *testing.T) {
var contractValueFlags = map[string]string{
"-s": "2", "-r": "origin", "-b": "main", "-m": "msg",
"-d": "+%Y", "-x": `\.log$`, "-g": "/tmp/gd",
"-l": "10", "-L": "10", "-c": "echo hi",
}

var contractBoolFlags = []string{"-R", "-M", "-f", "--paused"}
var contractBoolFlags = []string{"-R", "-M", "-f", "-C", "--paused"}
var contractCommands = []string{"add", "rm", "pause", "resume", "status",
"start", "stop", "autostart", "config", "help", "version"}

Expand Down
128 changes: 123 additions & 5 deletions linux/git_driver.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package main

import (
"bytes"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)

Expand Down Expand Up @@ -66,6 +69,22 @@ func gitRun(args []string, dir string, gitDir string) (int, string) {
return runCommand("git", full, dir)
}

// $() capture: stdout only, trailing newlines stripped, leading whitespace kept.
func gitCapture(args []string, dir string, gitDir string) string {
return strings.TrimRight(string(gitCaptureRaw(args, dir, gitDir)), "\n")
}

func gitCaptureRaw(args []string, dir string, gitDir string) []byte {
full := args
if gitDir != "" {
full = append([]string{"--work-tree", dir, "--git-dir", gitDir}, args...)
}
cmd := exec.Command("git", full...)
cmd.Dir = dir
out, _ := cmd.Output() // stdout even when git failed, as command substitution keeps
return out
}

func isRepo(dir string, gitDir string) bool {
_, out := gitRun([]string{"rev-parse", "--is-inside-work-tree"}, dir, gitDir)
return out == "true"
Expand Down Expand Up @@ -121,8 +140,103 @@ func hasRebaseInProgress(dir string, gitDir string) bool {
return gitStateExists([]string{"rebase-merge", "rebase-apply"}, dir, gitDir)
}

// Stage all, commit (honoring -m/-d/-M), then optionally pull --rebase (-R)
// and push (-r/-b). One gitwatch cycle.
var (
removedFileHeader = regexp.MustCompile(`--- (?:a/)?([^ \t\x1b]+)`)
addedFileHeader = regexp.MustCompile(`\+\+\+ (?:b/)?([^ \t\x1b]+)`)
hunkHeader = regexp.MustCompile(`@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,[0-9]+)? @@`)
changedLine = regexp.MustCompile(`^(?:\x1b\[[0-9;]+m)*([ +-])`)
)

// Bash's ${s:0:n}: bytes that are not valid UTF-8 count as one character each.
func cutToChars(s string, n int) string {
count := 0
for i := range s {
if count == n {
return s[:i]
}
count++
}
return s
}

// Upstream's diff-lines filter: hunk lines become "path:line: content".
func diffLines(diff string) string {
path, line, previousPath := "", "", ""
var out []string
for _, reply := range strings.Split(diff, "\n") {
if m := removedFileHeader.FindStringSubmatch(reply); m != nil {
previousPath = m[1]
} else if m := addedFileHeader.FindStringSubmatch(reply); m != nil {
path = m[1]
} else if m := hunkHeader.FindStringSubmatch(reply); m != nil {
line = m[1]
} else if m := changedLine.FindStringSubmatch(reply); m != nil {
reply = cutToChars(reply, 150)
if path == "/dev/null" {
out = append(out, "File "+previousPath+" deleted or moved.")
continue
}
out = append(out, path+":"+line+": "+reply)
if m[1] != "-" {
n, _ := strconv.Atoi(line)
line = strconv.Itoa(n + 1)
}
}
}
return strings.Join(out, "\n")
}

// -L's empty colour argument trips git > 2.39: the known bug the help text documents.
func listChangesMessage(spec *RepoSpec) string {
dir := spec.WorkDir()
colorArg := ""
if spec.ListChangesColor {
colorArg = "--color=always"
}
msg := diffLines(gitCapture([]string{"diff", "-U0", colorArg}, dir, spec.GitDir))
length := 0
if spec.ListChanges >= 1 && msg != "" {
length = len(strings.Split(msg, "\n"))
}
if length <= spec.ListChanges {
if msg != "" {
return msg
}
return "New files added: " + gitCapture([]string{"status", "-s"}, dir, spec.GitDir)
}
var stat []string
for _, l := range strings.Split(gitCapture([]string{"diff", "--stat"}, dir, spec.GitDir), "\n") {
if strings.Contains(l, "|") {
stat = append(stat, l)
}
}
return strings.Join(stat, "\n")
}

// Word-split into an argv, no shell; stdout is the message, exit status ignored.
func commitCommandOutput(spec *RepoSpec) string {
argv := strings.FieldsFunc(spec.CommitCommand, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' // bash's default IFS
})
if len(argv) == 0 {
return ""
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Dir = spec.WorkDir()
var stdout bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = os.Stderr // upstream leaves the command's stderr on the terminal
if spec.PipeChangedFiles {
cmd.Stdin = bytes.NewReader(gitCaptureRaw([]string{"diff", "--name-only"}, spec.WorkDir(), spec.GitDir))
}
cmd.Run()
// $() drops NUL bytes and trailing newlines.
out := bytes.ReplaceAll(stdout.Bytes(), []byte{0}, nil)
return strings.TrimRight(string(out), "\n")
}

// Stage all, commit (honoring -m/-d/-l/-L/-c/-C/-M), then optionally pull
// --rebase (-R) and push (-r/-b). One gitwatch cycle.
func autoCommit(spec *RepoSpec) Outcome {
dir := spec.WorkDir()
if spec.NoMergeCommit && hasMergeInProgress(dir, spec.GitDir) {
Expand All @@ -132,10 +246,14 @@ func autoCommit(spec *RepoSpec) Outcome {
return Outcome{Kind: Clean}
}

// 1. Build the message before add: upstream's order, and -c/-C (not
// ported yet) must see the unstaged tree. Upstream's ${COMMITMSG/\%d/...}
// splices the date into the first %d only.
// 1. Message before add, so -l/-L and -c/-C see the unstaged tree; each overrides the last.
msg := strings.Replace(spec.Message, "%d", formattedDate(spec.DateFormat), 1)
if spec.ListChanges >= 0 {
msg = listChangesMessage(spec)
}
if spec.CommitCommand != "" {
msg = commitCommandOutput(spec)
}

// 2. Stage upstream's GIT_ADD_ARGS: "--all ." scoped to the target
// directory, or just the file for a file target.
Expand Down
Loading
Loading