diff --git a/linux/cli.go b/linux/cli.go index 5095599..8b0da31 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -62,6 +62,16 @@ FLAGS (for add) Default: "gitwatchd auto-commit (%d)". -d Format string for that timestamp (see ` + "`man date`" + `). Default: "+%Y-%m-%d %H:%M:%S". + -l Use the diff itself as the commit message (file:line: change, + in colour), up to lines (0 = no limit). A diff + longer than falls back to the ` + "`git diff --stat`" + ` + summary. Overrides -m. + -L Same as -l, without colour. Known bug: on git versions > 2.39 + this falls back to a status summary. + -c Run 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 Skip changes whose path matches this regular expression (e.g. '\.log$' or 'build/'). -M Skip committing while the repo has a merge in progress. @@ -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] +// [-c cmd] [-C] [-l|-L lines] [-x pattern] [-M] [-g gitdir] +// [-e events] // // 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 { @@ -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 @@ -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 ` 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 { @@ -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: diff --git a/linux/cli_test.go b/linux/cli_test.go index cc5dc22..898ee96 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -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) { @@ -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 ` 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") { @@ -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"} diff --git a/linux/git_driver.go b/linux/git_driver.go index ce16db7..f5faa33 100644 --- a/linux/git_driver.go +++ b/linux/git_driver.go @@ -1,9 +1,12 @@ package main import ( + "bytes" "os" "os/exec" "path/filepath" + "regexp" + "strconv" "strings" ) @@ -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" @@ -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) { @@ -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. diff --git a/linux/git_driver_test.go b/linux/git_driver_test.go index b05d988..56a23d9 100644 --- a/linux/git_driver_test.go +++ b/linux/git_driver_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "strconv" @@ -66,7 +67,8 @@ func (r *testRepo) commitCount() int { return n } -func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%s") } +// %B, not %s: -l/-L and -c messages are multi-line. +func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%B") } func (r *testRepo) midMerge() bool { _, err := os.Stat(filepath.Join(r.path, ".git", "MERGE_HEAD")) @@ -144,7 +146,7 @@ func (b *bareRemote) commitCount() int { } func (b *bareRemote) lastMessage() string { - _, out := gitRun([]string{"log", "-1", "--pretty=%s", "main"}, b.path, "") + _, out := gitRun([]string{"log", "-1", "--pretty=%B", "main"}, b.path, "") return out } @@ -209,6 +211,231 @@ func TestOnlyTheFirstDateTokenIsExpanded(t *testing.T) { } } +func numberedLines(prefix string, n int) string { + var lines []string + for i := 1; i <= n; i++ { + lines = append(lines, fmt.Sprintf("%s%d", prefix, i)) + } + return strings.Join(lines, "\n") + "\n" +} + +func TestListChangesEmbedsTheColouredDiff(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "alpha\n") + autoCommit(repo.spec()) + repo.write("a.txt", "beta\n") + if got := autoCommit(repo.spec("-l", "10", "-m", "unused")); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + // Exact bytes vary with git version and colour config, so this asserts + // structure plus the presence of escapes; the parity suite pins the exact + // bytes differentially against the same git. + if !strings.Contains(repo.lastMessage(), "\x1b[") { + t.Errorf("-l keeps git's colour codes, got: %q", repo.lastMessage()) + } + if !strings.Contains(repo.lastMessage(), "a.txt:1: ") { + t.Errorf("hunk lines carry path:line:, got: %q", repo.lastMessage()) + } +} + +func TestListChangesCutsEachLineAt150Chars(t *testing.T) { + repo := newTestRepo(t) + repo.write("long.txt", "short\n") + autoCommit(repo.spec()) + repo.write("long.txt", strings.Repeat("x", 200)+"\n") + autoCommit(repo.spec("-l", "0")) + if !strings.Contains(repo.lastMessage(), strings.Repeat("x", 100)) { + t.Errorf("the long line is embedded, got: %q", repo.lastMessage()) + } + if strings.Contains(repo.lastMessage(), strings.Repeat("x", 150)) { + t.Error("cut at 150 chars of raw diff line (colour codes count)") + } + for _, line := range strings.Split(repo.lastMessage(), "\n") { + if len([]rune(line)) > len("long.txt:1: ")+150 { + t.Errorf("over-long line: %q", line) + } + } +} + +func TestListChangesZeroMeansUnlimited(t *testing.T) { + repo := newTestRepo(t) + repo.write("n.txt", numberedLines("old", 10)) + autoCommit(repo.spec()) + repo.write("n.txt", numberedLines("new", 10)) + autoCommit(repo.spec("-l", "0")) + lines := strings.Split(repo.lastMessage(), "\n") + if len(lines) != 20 { + t.Fatalf("10 removals + 10 additions, got: %q", repo.lastMessage()) + } + if !strings.HasPrefix(lines[0], "n.txt:1: ") || !strings.Contains(lines[0], "-old1") { + t.Errorf("removals keep the hunk's start line, got: %q", lines[0]) + } + last := lines[19] + if !strings.HasPrefix(last, "n.txt:10: ") || !strings.Contains(last, "new10") { + t.Errorf("additions advance the line number, got: %q", last) + } +} + +func TestListChangesAtExactlyTheCapStillEmbeds(t *testing.T) { + repo := newTestRepo(t) + repo.write("n.txt", numberedLines("old", 10)) + autoCommit(repo.spec()) + repo.write("n.txt", numberedLines("new", 10)) + autoCommit(repo.spec("-l", "20")) // the cap is "more than", not "at least" + if n := len(strings.Split(repo.lastMessage(), "\n")); n != 20 { + t.Errorf("20 diff lines fit in -l 20, got %d: %q", n, repo.lastMessage()) + } +} + +func TestListChangesBeyondTheCapFallsBackToTheDiffstat(t *testing.T) { + repo := newTestRepo(t) + repo.write("n.txt", numberedLines("old", 10)) + autoCommit(repo.spec()) + repo.write("n.txt", numberedLines("new", 10)) + autoCommit(repo.spec("-l", "5")) + if !strings.Contains(repo.lastMessage(), "n.txt |") { + t.Errorf("diffstat summary expected, got: %q", repo.lastMessage()) + } + if strings.Contains(repo.lastMessage(), "n.txt:1:") { + t.Error("no embedded diff lines") + } + if strings.Contains(repo.lastMessage(), "\x1b") { + t.Error("upstream's stat command takes no colour flag") + } +} + +func TestListChangesWithOnlyNewFilesListsThem(t *testing.T) { + repo := newTestRepo(t) + repo.write("seed.txt", "seed\n") + autoCommit(repo.spec()) + repo.write("fresh.txt", "hello\n") + autoCommit(repo.spec("-l", "10")) + if repo.lastMessage() != "New files added: ?? fresh.txt" { + t.Errorf("status is taken before git add, got: %q", repo.lastMessage()) + } +} + +func TestListChangesReportsADeletedFile(t *testing.T) { + repo := newTestRepo(t) + repo.write("doomed.txt", "contents\n") + repo.write("keep.txt", "kept\n") + autoCommit(repo.spec()) + os.Remove(filepath.Join(repo.path, "doomed.txt")) + autoCommit(repo.spec("-l", "10")) + if repo.lastMessage() != "File doomed.txt deleted or moved." { + t.Errorf("got: %q", repo.lastMessage()) + } +} + +// Upstream hands git the raw bytes it read; git transcodes them into the object, +// so assert on the built message, not the stored one. +func TestListChangesEmbedsANonUTF8Diff(t *testing.T) { + repo := newTestRepo(t) + file := filepath.Join(repo.path, "x.txt") + os.WriteFile(file, []byte("alpha\n"), 0o644) + autoCommit(repo.spec()) + os.WriteFile(file, []byte{0x62, 0xE9, 0x74, 0x61, 0x0A}, 0o644) // Latin-1 e-acute + msg := listChangesMessage(repo.spec("-l", "10")) + if !strings.Contains(msg, "x.txt:1: ") { + t.Errorf("a non-UTF-8 byte must not empty the diff, got: %q", msg) + } + if !strings.Contains(msg, "\xe9") { + t.Errorf("the invalid byte survives verbatim, got: %q", msg) + } +} + +// Pinned bug-for-bug: git > 2.39 rejects the empty colour argument as a pathspec, +// so every -L commit degrades to the status summary. + +func TestPlainListChangesDegradesToTheStatusSummary(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "alpha\n") + autoCommit(repo.spec()) + repo.write("a.txt", "beta\n") + if got := autoCommit(repo.spec("-L", "10", "-m", "unused")); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if repo.lastMessage() != "New files added: M a.txt" { + t.Errorf("upstream's degraded -L output, got: %q", repo.lastMessage()) + } +} + +func TestPlainListChangesNeverAppliesTheCap(t *testing.T) { + // The empty diff message always satisfies the length gate, so no -L value + // (small cap or 0 = unlimited) ever embeds a diff or a diffstat. + for _, cap := range []string{"5", "0"} { + repo := newTestRepo(t) + repo.write("n.txt", numberedLines("old", 10)) + autoCommit(repo.spec()) + repo.write("n.txt", numberedLines("new", 10)) + autoCommit(repo.spec("-L", cap)) + if repo.lastMessage() != "New files added: M n.txt" { + t.Errorf("-L %s: upstream's degraded output, got: %q", cap, repo.lastMessage()) + } + } +} + +// -c/-C: the command's stdout is the message, overriding -m, -d and -l/-L. + +func TestCommitCommandOutputBecomesTheMessage(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-c", "echo release notes", "-m", "unused")) + if repo.lastMessage() != "release notes" { + t.Errorf("got %q", repo.lastMessage()) + } +} + +func TestCommitCommandIsWordSplitNotShellInterpreted(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-c", "echo a && echo b")) + if repo.lastMessage() != "a && echo b" { + t.Errorf("got %q", repo.lastMessage()) + } +} + +func TestCommitCommandOverridesListChanges(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "alpha\n") + autoCommit(repo.spec()) + repo.write("a.txt", "beta\n") + autoCommit(repo.spec("-l", "10", "-m", "unused", "-c", "echo from the command")) + if repo.lastMessage() != "from the command" { + t.Errorf("-c is applied last, got %q", repo.lastMessage()) + } +} + +// Exit status is ignored; printing nothing leaves an empty -m, which git refuses. +func TestFailingCommitCommandAbortsTheCommit(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-c", "false", "-m", "unused")) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if repo.commitCount() != 0 { + t.Error("no commit is made with an empty message") + } +} + +func TestPipeChangedFilesFeedsTheCommandStdin(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "v1\n") + repo.write("b.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("a.txt", "v2\n") + repo.write("b.txt", "v2\n") + autoCommit(repo.spec("-c", "cat", "-C")) + if repo.lastMessage() != "a.txt\nb.txt" { + t.Errorf("`git diff --name-only` of the unstaged tree, got %q", repo.lastMessage()) + } + repo.write("a.txt", "v3\n") + if got := autoCommit(repo.spec("-c", "cat")); got.Kind != CommitFailed { + t.Errorf("without -C the command reads /dev/null and prints nothing: %+v", got) + } +} + func TestPushToRemote(t *testing.T) { repo := newTestRepo(t) origin := repo.addOrigin() @@ -510,3 +737,21 @@ func TestRebaseConflictIsReportedAndLeftInProgress(t *testing.T) { t.Error("the conflicted rebase is left in progress") } } + +// Matching macOS, not upstream: upstream runs the -c command on every cycle, +// even with nothing to commit; we short-circuit first. +func TestCommitCommandDoesNotRunOnACleanCycle(t *testing.T) { + repo := newTestRepo(t) + repo.commit("a.txt", "v1\n", "seed") + marker := filepath.Join(t.TempDir(), "ran") + spec, errMsg := parseRepoSpec([]string{"-c", "touch " + marker, repo.path}) + if spec == nil { + t.Fatal(errMsg) + } + if got := autoCommit(spec); got.Kind != Clean { + t.Fatalf("got %v", got) + } + if _, err := os.Stat(marker); err == nil { + t.Error("the -c command ran on a clean cycle") + } +} diff --git a/linux/gitwatch_parity_test.go b/linux/gitwatch_parity_test.go index d7692bb..1098a29 100644 --- a/linux/gitwatch_parity_test.go +++ b/linux/gitwatch_parity_test.go @@ -193,6 +193,121 @@ func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { } } +// These scenarios compare whole commit-message bodies against the oracle. + +func TestParityListChangesEmbedsTheDiff(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "tracked") + repo.write("notes.txt", "hello again\n") + }) + expectParity(t, model, ours) + if !strings.Contains(ours.lastMessage, "notes.txt:1: ") || !strings.Contains(ours.lastMessage, "\x1b[") { + t.Errorf("both sides embed the coloured diff: %q", ours.lastMessage) + } +} + +func TestParityListChangesCutsLongLines(t *testing.T) { + model, ours := twins(t, []string{"-l", "0"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("long.txt", "short\n", "seed") + repo.write("long.txt", strings.Repeat("x", 200)+"\n") + }) + expectParity(t, model, ours) + if strings.Contains(ours.lastMessage, strings.Repeat("x", 150)) { + t.Errorf("both sides cut at 150 characters: %q", ours.lastMessage) + } +} + +func TestParityListChangesFallsBackToTheDiffstat(t *testing.T) { + model, ours := twins(t, []string{"-l", "5"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("n.txt", numberedLines("old", 10), "seed") + repo.write("n.txt", numberedLines("new", 10)) + }) + expectParity(t, model, ours) + if !strings.Contains(ours.lastMessage, "n.txt |") { + t.Errorf("20 diff lines exceed -l 5, so both sides summarise: %q", ours.lastMessage) + } +} + +func TestParityListChangesWithOnlyNewFiles(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("fresh.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.lastMessage != "New files added: ?? fresh.txt" { + t.Errorf("got %q", ours.lastMessage) + } +} + +// -L's empty colour argument makes both sides run `git diff -U0 ""`, which +// this git rejects, so both degrade to the status summary. +func TestParityPlainListChangesDegrades(t *testing.T) { + model, ours := twins(t, []string{"-L", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "alpha\n", "seed") + repo.write("a.txt", "beta\n") + }) + expectParity(t, model, ours) + if ours.lastMessage != "New files added: M a.txt" { + t.Errorf("got %q", ours.lastMessage) + } +} + +// "&&" is an argument to echo, not a shell operator. +func TestParityCommitCommandIsWordSplit(t *testing.T) { + model, ours := twins(t, []string{"-c", "echo a && echo b"}, false, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.lastMessage != "a && echo b" { + t.Errorf("got %q", ours.lastMessage) + } +} + +// The trailing -f is upstream's `C:` bug showing through: -C swallows the next +// token, so feeding it a duplicate -f keeps both sides' effective flags equal. +func TestParityCommitCommandReadsChangedFilesFromStdin(t *testing.T) { + model, ours := twins(t, []string{"-c", "cat", "-C", "-f"}, false, "", + func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "v1\n", "seed") + repo.commit("b.txt", "v1\n", "seed b") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "v2\n") + }) + expectParity(t, model, ours) + if ours.lastMessage != "a.txt\nb.txt" { + t.Errorf("got %q", ours.lastMessage) + } +} + +// Exit status is ignored; printing nothing leaves an empty -m on both sides. +func TestParityFailingCommitCommandCommitsNothing(t *testing.T) { + model, ours := twins(t, []string{"-c", "false"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 1 || ours.pendingChanges == 0 { + t.Errorf("the change stays uncommitted on both sides: %v", ours) + } +} + +// -v turns on upstream's tracing and changes nothing about the repo. +func TestParityVerboseChangesNothing(t *testing.T) { + model, ours := twins(t, []string{"-v", "-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.lastMessage != "cycle" { + t.Errorf("got %v", ours) + } +} + func TestParityPushToRemote(t *testing.T) { model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", func(repo *testRepo, _ *bareRemote) { @@ -314,3 +429,45 @@ func TestParityRebaseConflictLeftInProgress(t *testing.T) { t.Error("both sides stop mid-rebase; -M is the only guard") } } + +// Pinned upstream bug: the unanchored header regexes run before the content one, +// so a changed line whose text contains "--- x" is eaten and corrupts the path. +func TestParityListChangesEatsContentLinesResemblingHeaders(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "alpha\n", "seed") + repo.write("a.txt", "alpha\n--- section two\nbeta\n") + }) + expectParity(t, model, ours) + if strings.Contains(ours.lastMessage, "section") || !strings.Contains(ours.lastMessage, "beta") { + t.Errorf("got %q", ours.lastMessage) + } +} + +// Pinned upstream bug: the 150-char cut counts colour escapes and can land inside +// one, leaving a dangling ESC in the commit message. +func TestParityListChangesCutCanSplitAnEscape(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "short\n", "seed") + // 135 content chars put char 150 inside this git's trailing \x1b[m. + repo.write("a.txt", strings.Repeat("x", 135)+"\n") + }) + expectParity(t, model, ours) + if !strings.HasSuffix(ours.lastMessage, "\x1b") && !strings.HasSuffix(ours.lastMessage, "\x1b[") { + t.Errorf("expected a dangling escape, got %q", ours.lastMessage) + } +} + +// Pinned upstream quirk: the -l diff is repo-wide even for a file target, so the +// message can describe changes the commit does not include. +func TestParityListChangesOnAFileTargetEmbedsTheWholeRepoDiff(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "v1\n", "seed") + repo.commit("b.txt", "v1\n", "seed b") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "v2\n") + }) + expectParity(t, model, ours) + if !strings.Contains(ours.lastMessage, "b.txt:") || ours.pendingChanges == 0 { + t.Errorf("the message names b.txt yet b.txt stays uncommitted: %v", ours) + } +}