├── staticcheck.conf
├── .gitignore
├── gow_const_linux.go
├── gow_const_bsd.go
├── go.mod
├── gow_ps_bsd.go
├── go.sum
├── gow_ps_darwin.go
├── gow_watch_notify.go
├── unlicense
├── gow_sig.go
├── gow_ps_linux.go
├── gow_ps_ps.go
├── gow_flag.go
├── gow_cmd.go
├── makefile
├── gow_stdio.go
├── gow_misc.go
├── gow_main.go
├── gow_opt.go
├── gow_term.go
├── gow_test.go
└── readme.md
/staticcheck.conf:
--------------------------------------------------------------------------------
1 | checks = ["all", "-ST1003", "-ST1006", "-ST1020", "-ST1021", "-ST1022"]
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .*
2 | /*
3 | !/.gitignore
4 | !/*.go
5 | touched.go
6 | !/readme.md
7 | !/makefile
8 | !/dockerfile
9 | !/staticcheck.conf
10 | !/unlicense
--------------------------------------------------------------------------------
/gow_const_linux.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "golang.org/x/sys/unix"
4 |
5 | const ioctlReadTermios = unix.TCGETS
6 | const ioctlWriteTermios = unix.TCSETS
7 |
--------------------------------------------------------------------------------
/gow_const_bsd.go:
--------------------------------------------------------------------------------
1 | //go:build darwin || dragonfly || freebsd || netbsd || openbsd
2 |
3 | package main
4 |
5 | import "golang.org/x/sys/unix"
6 |
7 | const ioctlReadTermios = unix.TIOCGETA
8 | const ioctlWriteTermios = unix.TIOCSETA
9 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/mitranim/gow
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.24.0
6 |
7 | require (
8 | github.com/mitranim/gg v0.1.29
9 | github.com/rjeczalik/notify v0.9.3
10 | golang.org/x/sys v0.31.0
11 | golang.org/x/term v0.30.0
12 | )
13 |
--------------------------------------------------------------------------------
/gow_ps_bsd.go:
--------------------------------------------------------------------------------
1 | //go:build !(darwin || linux)
2 |
3 | package main
4 |
5 | /*
6 | On systems where we don't implement a "native" fast PS,
7 | we fall back on shelling out to `ps`.
8 | */
9 | func SubPids(topPid int, _ bool) ([]int, error) {
10 | return SubPidsViaPs(topPid)
11 | }
12 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/mitranim/gg v0.1.29 h1:Jpu9tWq+RpDoeMds3KyG0ebErsxXKqGBiP34VBCIidA=
2 | github.com/mitranim/gg v0.1.29/go.mod h1:x2V+nJJOpeMl/XEoHou9zlTvFxYAcGOCqOAKpVkF0Yc=
3 | github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
4 | github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc=
5 | golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
6 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
7 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
8 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
9 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
10 |
--------------------------------------------------------------------------------
/gow_ps_darwin.go:
--------------------------------------------------------------------------------
1 | //go:build darwin
2 |
3 | package main
4 |
5 | import (
6 | "os"
7 |
8 | "github.com/mitranim/gg"
9 | "golang.org/x/sys/unix"
10 | )
11 |
12 | func SubPids(topPid int, verb bool) ([]int, error) {
13 | pid := os.Getpid()
14 | pids, err := SubPidsViaSyscall(pid)
15 | if err == nil {
16 | return pids, nil
17 | }
18 | if verb {
19 | log.Println(`unable to get pids via syscall, falling back on "ps":`, err)
20 | }
21 | return SubPidsViaPs(pid)
22 | }
23 |
24 | func SubPidsViaSyscall(topPid int) ([]int, error) {
25 | // Get all processes.
26 | infos, err := unix.SysctlKinfoProcSlice(`kern.proc.all`)
27 | if err != nil {
28 | return nil, gg.Wrap(err, `failed to get process list`)
29 | }
30 |
31 | // Index child pids by ppid.
32 | ppidToPids := make(map[int][]int, len(infos))
33 | for _, info := range infos {
34 | pid := int(info.Proc.P_pid)
35 | ppid := int(info.Eproc.Ppid)
36 | ppidToPids[ppid] = append(ppidToPids[ppid], pid)
37 | }
38 |
39 | return procIndexToDescs(ppidToPids, topPid, 0), nil
40 | }
41 |
--------------------------------------------------------------------------------
/gow_watch_notify.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "path/filepath"
5 |
6 | "github.com/mitranim/gg"
7 | "github.com/rjeczalik/notify"
8 | )
9 |
10 | // Implementation of `Watcher` that uses "github.com/rjeczalik/notify".
11 | type WatchNotify struct {
12 | Mained
13 | Done gg.Chan[struct{}]
14 | Events gg.Chan[notify.EventInfo]
15 | }
16 |
17 | func (self *WatchNotify) Init(main *Main) {
18 | self.Mained.Init(main)
19 | self.Done.Init()
20 | self.Events.InitCap(1)
21 |
22 | paths := main.Opt.WatchDirs
23 | verb := main.Opt.Verb && !gg.Equal(paths, OptDefault().WatchDirs)
24 |
25 | for _, path := range paths {
26 | // In "github.com/rjeczalik/notify", the "..." syntax is used to signify
27 | // recursive watching.
28 | path = filepath.Join(path, `...`)
29 | if verb {
30 | log.Printf(`watching %q`, path)
31 | }
32 | gg.Try(notify.Watch(path, self.Events, notify.All))
33 | }
34 | }
35 |
36 | func (self *WatchNotify) Deinit() {
37 | self.Done.SendZeroOpt()
38 | if self.Events != nil {
39 | notify.Stop(self.Events)
40 | }
41 | }
42 |
43 | func (self WatchNotify) Run() {
44 | main := self.Main()
45 |
46 | for {
47 | select {
48 | case <-self.Done:
49 | return
50 | case event := <-self.Events:
51 | main.OnFsEvent(event)
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/unlicense:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
25 |
--------------------------------------------------------------------------------
/gow_sig.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | "os/signal"
6 | "syscall"
7 |
8 | "github.com/mitranim/gg"
9 | )
10 |
11 | /*
12 | Internal tool for handling OS signals.
13 | */
14 | type Sig struct {
15 | Mained
16 | Chan gg.Chan[os.Signal]
17 | }
18 |
19 | /*
20 | This removes our custom handling of OS signals, falling back on the default
21 | behavior of the Go runtime. This doesn't bother to stop the `(*Sig).Run`
22 | goroutine. Terminating the entire `gow` process takes care of that.
23 | */
24 | func (self *Sig) Deinit() {
25 | if self.Chan != nil {
26 | signal.Stop(self.Chan)
27 | }
28 | }
29 |
30 | /*
31 | We override Go's default signal handling to ensure cleanup before exit.
32 | Clean includes restoring the previous terminal state and broadcasting
33 | kill signals to any descendant processes. Without this override, some
34 | OS signals would kill us without allowing us to run cleanup.
35 |
36 | The set of signals registered here MUST match the set of signals explicitly
37 | handled by this program; see below.
38 | */
39 | func (self *Sig) Init(main *Main) {
40 | self.Mained.Init(main)
41 | self.Chan.InitCap(1)
42 | signal.Notify(self.Chan, KILL_SIGS_OS...)
43 | }
44 |
45 | func (self *Sig) Run() {
46 | main := self.Main()
47 |
48 | for val := range self.Chan {
49 | // Should work on all Unix systems. At the time of writing,
50 | // we're not prepared to support other systems.
51 | sig := val.(syscall.Signal)
52 |
53 | if KILL_SIG_SET.Has(sig) {
54 | if main.Opt.Verb {
55 | log.Println(`received kill signal:`, sig)
56 | }
57 | main.Kill(sig)
58 | continue
59 | }
60 |
61 | if main.Opt.Verb {
62 | log.Println(`received unknown signal:`, sig)
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/gow_ps_linux.go:
--------------------------------------------------------------------------------
1 | //go:build linux
2 |
3 | package main
4 |
5 | import (
6 | "os"
7 | "path/filepath"
8 | "strconv"
9 | "strings"
10 |
11 | "github.com/mitranim/gg"
12 | )
13 |
14 | func SubPids(topPid int, verb bool) ([]int, error) {
15 | pid := os.Getpid()
16 | pids, err := SubPidsViaProcDir(pid)
17 | if err == nil {
18 | return pids, nil
19 | }
20 | if verb {
21 | log.Println(`unable to get pids from "/proc", falling back on "ps":`, err)
22 | }
23 | return SubPidsViaPs(pid)
24 | }
25 |
26 | func SubPidsViaProcDir(topPid int) ([]int, error) {
27 | procEntries, err := os.ReadDir(`/proc`)
28 | if err != nil {
29 | return nil, gg.Wrap(err, `unable to read directory "/proc"`)
30 | }
31 |
32 | // Index of child pids by ppid.
33 | ppidToPids := map[int][]int{}
34 |
35 | for _, entry := range procEntries {
36 | if !entry.IsDir() {
37 | continue
38 | }
39 |
40 | pidStr := entry.Name()
41 | pid, err := strconv.Atoi(pidStr)
42 | if err != nil {
43 | // Non-numeric names don't describe processes, skip.
44 | continue
45 | }
46 |
47 | status, err := os.ReadFile(filepath.Join(`/proc`, pidStr, `status`))
48 | if err != nil {
49 | // Process may have terminated, skip.
50 | continue
51 | }
52 |
53 | ppid := statusToPpid(gg.ToString(status))
54 | if ppid != 0 {
55 | ppidToPids[ppid] = append(ppidToPids[ppid], pid)
56 | }
57 | }
58 |
59 | return procIndexToDescs(ppidToPids, topPid, 0), nil
60 | }
61 |
62 | func statusToPpid(src string) (_ int) {
63 | const prefix0 = `PPid:`
64 | const prefix1 = `Ppid:`
65 |
66 | ind := strings.Index(src, prefix0)
67 | if ind >= 0 {
68 | ind += len(prefix0)
69 | } else {
70 | ind = strings.Index(src, prefix1)
71 | if ind < 0 {
72 | return
73 | }
74 | ind += len(prefix1)
75 | }
76 |
77 | src = src[ind:]
78 | ind = strings.Index(src, "\n")
79 | if ind < 0 {
80 | return
81 | }
82 | src = src[:ind]
83 | src = strings.TrimSpace(src)
84 |
85 | out, _ := strconv.Atoi(src)
86 | return out
87 | }
88 |
--------------------------------------------------------------------------------
/gow_ps_ps.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os/exec"
5 | "regexp"
6 | "strconv"
7 |
8 | "github.com/mitranim/gg"
9 | )
10 |
11 | func SubPidsViaPs(topPid int) ([]int, error) {
12 | cmd := exec.Command(`ps`, `-eo`, `pid=,ppid=`)
13 | var buf gg.Buf
14 | cmd.Stdout = &buf
15 |
16 | err := cmd.Run()
17 | if err != nil {
18 | return nil, gg.Wrap(err, `unexpected error: unable to invoke "ps" to get subprocess pids`)
19 | }
20 |
21 | var psPid int
22 | if cmd.Process != nil {
23 | psPid = cmd.Process.Pid
24 | }
25 | return PsOutToSubPids(buf.String(), topPid, psPid), nil
26 | }
27 |
28 | func PsOutToSubPids(src string, topPid, skipPid int) []int {
29 | return procIndexToDescs(procPairsToIndex(psOutToProcPairs(src)), topPid, skipPid)
30 | }
31 |
32 | /*
33 | Takes an index that maps ppids to child pids, and returns a slice of all
34 | descendant pids of the given ppid, skipping `skipPid`, sorted descending.
35 | */
36 | func procIndexToDescs(src map[int][]int, topPid, skipPid int) []int {
37 | found := gg.Set[int]{}
38 | for _, val := range src[topPid] {
39 | if val != skipPid {
40 | addProcDescs(src, val, found)
41 | }
42 | }
43 | return sortPids(found)
44 | }
45 |
46 | func sortPids(src gg.Set[int]) []int {
47 | out := gg.MapKeys(src)
48 | gg.SortPrim(out)
49 | gg.Reverse(out)
50 | return out
51 | }
52 |
53 | func addProcDescs(src map[int][]int, ppid int, out gg.Set[int]) {
54 | if out.Has(ppid) {
55 | return
56 | }
57 | out.Add(ppid)
58 | for _, val := range src[ppid] {
59 | addProcDescs(src, val, out)
60 | }
61 | }
62 |
63 | func procPairsToIndex(src [][2]int) map[int][]int {
64 | out := map[int][]int{}
65 | for _, val := range src {
66 | out[val[1]] = append(out[val[1]], val[0])
67 | }
68 | return out
69 | }
70 |
71 | func psOutToProcPairs(src string) [][2]int {
72 | return gg.MapCompact(gg.SplitLines(src), lineToProcPair)
73 | }
74 |
75 | func lineToProcPair(src string) (_ [2]int) {
76 | out := rePsLine.FindStringSubmatch(src)
77 | if len(out) == 3 {
78 | return [2]int{parsePid(out[1]), parsePid(out[2])}
79 | }
80 | return
81 | }
82 |
83 | var rePsLine = regexp.MustCompile(`^\s*(-?\d+)\s+(-?\d+)\s*$`)
84 |
85 | func parsePid(src string) int {
86 | return int(gg.Try1(strconv.ParseInt(src, 10, 32)))
87 | }
88 |
--------------------------------------------------------------------------------
/gow_flag.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "io"
5 | "strings"
6 |
7 | "github.com/mitranim/gg"
8 | )
9 |
10 | type FlagStrMultiline string
11 |
12 | func (self *FlagStrMultiline) Parse(src string) error {
13 | *self += FlagStrMultiline(withNewline(REP_SINGLE_MULTI.Replace(src)))
14 | return nil
15 | }
16 |
17 | func (self FlagStrMultiline) Dump(out io.Writer) {
18 | if len(self) > 0 && out != nil {
19 | gg.Nop2(out.Write(gg.ToBytes(self)))
20 | }
21 | }
22 |
23 | type FlagExtensions []string
24 |
25 | func (self *FlagExtensions) Parse(src string) (err error) {
26 | defer gg.Rec(&err)
27 | vals := commaSplit(src)
28 | gg.Each(vals, validateExtension)
29 | gg.Append(self, vals...)
30 | return
31 | }
32 |
33 | func (self FlagExtensions) Allow(path string) bool {
34 | return gg.IsEmpty(self) || gg.Has(self, cleanExtension(path))
35 | }
36 |
37 | type FlagWatchDirs []string
38 |
39 | func (self *FlagWatchDirs) Parse(src string) error {
40 | gg.Append(self, commaSplit(src)...)
41 | return nil
42 | }
43 |
44 | type FlagIgnoreDirs []string
45 |
46 | func (self *FlagIgnoreDirs) Parse(src string) error {
47 | vals := FlagIgnoreDirs(commaSplit(src))
48 | vals.Norm()
49 | gg.Append(self, vals...)
50 | return nil
51 | }
52 |
53 | func (self FlagIgnoreDirs) Norm() { gg.MapMut(self, toAbsDirPath) }
54 |
55 | func (self FlagIgnoreDirs) Allow(path string) bool { return !self.Ignore(path) }
56 |
57 | /*
58 | Assumes that the input is an absolute path.
59 | TODO: also ignore if the directory path is an exact match.
60 | */
61 | func (self FlagIgnoreDirs) Ignore(path string) bool {
62 | return gg.Some(self, func(val string) bool {
63 | return strings.HasPrefix(path, val)
64 | })
65 | }
66 |
67 | const (
68 | EchoModeNone EchoMode = 0
69 | EchoModeGow EchoMode = 1
70 | EchoModePreserve EchoMode = 2
71 | )
72 |
73 | var EchoModes = []EchoMode{
74 | EchoModeNone,
75 | EchoModeGow,
76 | EchoModePreserve,
77 | }
78 |
79 | type EchoMode byte
80 |
81 | func (self EchoMode) String() string {
82 | switch self {
83 | case EchoModeNone:
84 | return ``
85 | case EchoModeGow:
86 | return `gow`
87 | case EchoModePreserve:
88 | return `preserve`
89 | default:
90 | panic(self.errInvalid())
91 | }
92 | }
93 |
94 | func (self *EchoMode) Parse(src string) error {
95 | switch src {
96 | case ``:
97 | *self = EchoModeNone
98 | case `gow`:
99 | *self = EchoModeGow
100 | case `preserve`:
101 | *self = EchoModePreserve
102 | default:
103 | return gg.Errf(`unsupported echo mode %q; supported modes: %q`, src, gg.Map(EchoModes, EchoMode.String))
104 | }
105 | return nil
106 | }
107 |
108 | func (self EchoMode) errInvalid() error {
109 | return gg.Errf(`invalid echo mode %v; valid modes: %v`, self, EchoModes)
110 | }
111 |
--------------------------------------------------------------------------------
/gow_cmd.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | "os/exec"
6 | "sync/atomic"
7 | "syscall"
8 | "time"
9 |
10 | "github.com/mitranim/gg"
11 | )
12 |
13 | type Cmd struct {
14 | Mained
15 | Count atomic.Int64
16 | }
17 |
18 | func (self *Cmd) Deinit() {
19 | if self.Count.Load() > 0 {
20 | self.Broadcast(syscall.SIGTERM)
21 | }
22 | }
23 |
24 | /*
25 | Note: proc count may change immediately after the call. Decision making at the
26 | callsite must account for this.
27 | */
28 | func (self *Cmd) IsRunning() bool { return self.Count.Load() == 0 }
29 |
30 | func (self *Cmd) Restart() {
31 | self.Deinit()
32 |
33 | main := self.Main()
34 | opt := main.Opt
35 | cmd := exec.Command(opt.Cmd, opt.Args...)
36 |
37 | if !main.Term.IsActive() {
38 | cmd.Stdin = os.Stdin
39 | }
40 | cmd.Stdout = os.Stdout
41 | cmd.Stderr = os.Stderr
42 |
43 | err := cmd.Start()
44 | if err != nil {
45 | log.Println(`unable to start subcommand:`, err)
46 | return
47 | }
48 |
49 | self.Count.Add(1)
50 | go self.ReportCmd(cmd, time.Now())
51 | }
52 |
53 | func (self *Cmd) ReportCmd(cmd *exec.Cmd, start time.Time) {
54 | defer self.Count.Add(-1)
55 | opt := self.Main().Opt
56 | opt.LogCmdExit(cmd.Wait(), time.Since(start))
57 | opt.TermSuf()
58 | }
59 |
60 | /*
61 | Sends the signal to all subprocesses (descendants included).
62 |
63 | Worth mentioning: across all the various Go versions tested (1.11 to 1.24), it
64 | seemed that the `go` commands such as `go run` or `go test` do not forward any
65 | interrupt or kill signals to its subprocess, and neither does `go test`. For
66 | us, this means that terminating the immediate child process is worth very
67 | little; we're concerned with terminating the grand-child processes, which may
68 | be spawned by the common cases `go run`, `go test`, or any replacement commands
69 | from `Opt.Cmd`.
70 |
71 | In the past, we used subprocess groups for broadcasts. When spawning the child
72 | process, we used `&syscall.SysProcAttr{Setpgid: true}`, and when broadcasting
73 | a signal, we would send it to `-proc.Pid`. Which did seem to work for killing
74 | descendant processes. But creating a subprocess group interferes with stdio and
75 | TTY detection in descendant processes, so we had to give it up, replacing with
76 | the solution below.
77 | */
78 | func (self *Cmd) Broadcast(sig syscall.Signal) {
79 | verb := self.Main().Opt.Verb
80 | pids, err := SubPids(os.Getpid(), verb)
81 | if err != nil {
82 | log.Println(err)
83 | return
84 | }
85 | if gg.IsEmpty(pids) {
86 | return
87 | }
88 |
89 | if !verb {
90 | for _, pid := range pids {
91 | gg.Nop1(syscall.Kill(pid, sig))
92 | return
93 | }
94 | }
95 |
96 | var sent []int
97 | var unsent []int
98 | var errs []error
99 |
100 | for _, pid := range pids {
101 | err := syscall.Kill(pid, sig)
102 | if err != nil {
103 | unsent = append(unsent, pid)
104 | errs = append(errs, err)
105 | } else {
106 | sent = append(sent, pid)
107 | }
108 | }
109 |
110 | if gg.IsEmpty(errs) {
111 | log.Printf(
112 | `sent signal %q to %v subprocesses, pids: %v`,
113 | sig, len(pids), sent,
114 | )
115 | } else {
116 | log.Printf(
117 | `tried to send signal %q to %v subprocesses, sent to pids: %v, not sent to pids: %v, errors: %q`,
118 | sig, len(pids), sent, unsent, errs,
119 | )
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/makefile:
--------------------------------------------------------------------------------
1 | # README
2 | #
3 | # This file is intended as an example for users of `gow`. It was adapted from a
4 | # larger Go project. At the time of writing, `gow` does not support its own
5 | # configuration files. For complex use cases, users are expected to use Make or
6 | # another similar tool.
7 | #
8 | # Despite being primarily an example, this file contains actual working rules
9 | # convenient for hacking on `gow`.
10 |
11 | # This variable `MAKEFLAGS` is special: it modifies Make's own behaviors.
12 | #
13 | # `--silent` causes Make to execute rules without additional verbose logging.
14 | # Without this, we would have to prefix each line in each rule with `@` to
15 | # suppress logging.
16 | #
17 | # `--always-make` makes all rules "abstract". It causes Make to execute each
18 | # rule without checking for an existing file matching the pattern represented
19 | # by the rule name. This is equivalent to marking every rule with `.PHONY`, but
20 | # keeps our makefile cleaner. In projects where some rule names are file names
21 | # or artifact path patterns, this should be removed, and abstract rules should
22 | # be explicitly marked with `.PHONY`.
23 | MAKEFLAGS := --silent --always-make
24 |
25 | # Shortcut for executing rules concurrently. See usage examples below.
26 | MAKE_CONC := $(MAKE) -j 128 CONC=true clear=$(or $(clear),false)
27 |
28 | VERB ?= $(if $(filter false,$(verb)),,-v)
29 | CLEAR ?= $(if $(filter false,$(clear)),,-c)
30 | GO_SRC ?= .
31 | GO_PKG ?= ./$(or $(pkg),$(GO_SRC)/...)
32 | GO_FLAGS ?= -tags=$(tags) -mod=mod
33 | GO_RUN_ARGS ?= $(GO_FLAGS) $(GO_SRC) $(run)
34 | GO_TEST_FAIL ?= $(if $(filter false,$(fail)),,-failfast)
35 | GO_TEST_SHORT ?= $(if $(filter true,$(short)), -short,)
36 | GO_TEST_FLAGS ?= -count=1 $(GO_FLAGS) $(VERB) $(GO_TEST_FAIL) $(GO_TEST_SHORT)
37 | GO_TEST_PATTERNS ?= -run="$(run)"
38 | GO_TEST_ARGS ?= $(GO_PKG) $(GO_TEST_FLAGS) $(GO_TEST_PATTERNS)
39 | IS_TTY ?= $(shell test -t 0 && printf " ")
40 |
41 | # Only one `gow` per terminal is allowed to use raw mode.
42 | # Otherwise they conflict with each other.
43 | GOW_HOTKEYS ?= -r=$(if $(filter true,$(CONC)),,$(if $(IS_TTY),true,false))
44 |
45 | GOW_FLAGS ?= $(CLEAR) $(VERB) $(GOW_HOTKEYS)
46 |
47 | # Expects an existing stable version of `gow`.
48 | GOW ?= gow $(GOW_FLAGS)
49 |
50 | watch:
51 | $(MAKE_CONC) dev_test_w dev_vet_w
52 |
53 | # If everything works properly, then we should see a message about the FS event
54 | # (file change), and tests should rerun. And, if everything _really_ works
55 | # properly, modifying local files should trigger FS events in the container,
56 | # causing `gow` to restart the test.
57 | watch_linux:
58 | podman run --rm -it -v=$(PWD):/gow -w=/gow golang:alpine sleep 3 && echo 'package main' > touched.go & go run . -v test -v
59 |
60 | all:
61 | $(MAKE_CONC) test vet
62 |
63 | dev_test_w:
64 | go run $(GO_RUN_ARGS) $(GOW_FLAGS) test $(GO_TEST_FLAGS)
65 |
66 | test_w:
67 | $(GOW) test $(GO_TEST_ARGS)
68 |
69 | test:
70 | go test $(GO_TEST_ARGS)
71 |
72 | dev_vet_w:
73 | go run $(GO_RUN_ARGS) $(GOW_FLAGS) vet $(GO_FLAGS)
74 |
75 | vet_w:
76 | $(GOW) vet $(GO_FLAGS)
77 |
78 | vet:
79 | go vet $(GO_FLAGS)
80 |
81 | check:
82 | gopls check *.go
83 |
84 | run_w:
85 | $(GOW) run $(GO_RUN_ARGS)
86 |
87 | run:
88 | go run $(GO_RUN_ARGS)
89 |
90 | install:
91 | go install $(GO_FLAGS) $(GO_SRC)
92 |
93 | # This uses another watcher because if we're repeatedly reinstalling `gow`,
94 | # then it's because we're experimenting and it's probably broken.
95 | install_w:
96 | watchexec -n -r -d=1ms -- go install $(GO_FLAGS) $(GO_SRC)
97 |
--------------------------------------------------------------------------------
/gow_stdio.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "io"
6 | "os"
7 | "syscall"
8 | "time"
9 |
10 | "github.com/mitranim/gg"
11 | )
12 |
13 | // Used by the ^C hotkey.
14 | const DoubleInputDelay = time.Second
15 |
16 | /*
17 | Standard input/output adapter for terminal raw mode. Raw mode allows us to
18 | support our own control codes, but we're also responsible for interpreting
19 | common ASCII codes into OS signals, and optionally for echoing other characters
20 | to stdout. This adapter is unnecessary in non-raw mode where we simply pipe
21 | stdio to/from the child process.
22 | */
23 | type Stdio struct {
24 | Mained
25 | LastChar byte
26 | LastInst time.Time
27 | }
28 |
29 | /*
30 | Doesn't require special cleanup before stopping `gow`. We run only one stdio
31 | loop, without ever replacing it.
32 | */
33 | func (*Stdio) Deinit() {}
34 |
35 | /*
36 | TODO: ideally, this should be used only in terminal raw mode. See the comment in
37 | `Cmd.MakeCmd` that explains the issue.
38 | */
39 | func (self *Stdio) Run() {
40 | self.LastInst = time.Now()
41 |
42 | for {
43 | var buf [1]byte
44 | size, err := os.Stdin.Read(buf[:])
45 | if errors.Is(err, io.EOF) {
46 | return
47 | }
48 | if err != nil {
49 | log.Println(`error when reading stdin, shutting down stdio:`, err)
50 | return
51 | }
52 | gg.Try(err)
53 | if size <= 0 {
54 | return
55 | }
56 | self.OnByte(buf[0])
57 | }
58 | }
59 |
60 | /*
61 | Interpret known ASCII codes as OS signals.
62 | Otherwise forward the input to the subprocess.
63 | */
64 | func (self *Stdio) OnByte(char byte) {
65 | defer recLog()
66 | defer self.AfterByte(char)
67 |
68 | switch char {
69 | case CODE_INTERRUPT:
70 | self.OnCodeInterrupt()
71 |
72 | case CODE_QUIT:
73 | self.OnCodeQuit()
74 |
75 | case CODE_PRINT_COMMAND:
76 | self.OnCodePrintCommand()
77 |
78 | case CODE_RESTART:
79 | self.OnCodeRestart()
80 |
81 | case CODE_STOP:
82 | self.OnCodeStop()
83 |
84 | case CODE_PRINT_HELP, CODE_PRINT_HELP_MACOS:
85 | self.OnCodePrintHelp()
86 |
87 | default:
88 | self.OnByteAny(char)
89 | }
90 | }
91 |
92 | func (self *Stdio) AfterByte(char byte) {
93 | self.LastChar = char
94 | self.LastInst = time.Now()
95 | }
96 |
97 | func (self *Stdio) OnCodeInterrupt() {
98 | self.OnCodeSig(CODE_INTERRUPT, syscall.SIGINT, `^C`)
99 | }
100 |
101 | func (self *Stdio) OnCodeQuit() {
102 | self.OnCodeSig(CODE_QUIT, syscall.SIGQUIT, `^\`)
103 | }
104 |
105 | // TODO include all current subproces with their args.
106 | func (*Stdio) OnCodePrintCommand() {
107 | log.Printf(`current command: %q`, os.Args)
108 | }
109 |
110 | func (*Stdio) OnCodePrintHelp() { log.Println(HOTKEY_HELP) }
111 |
112 | func (self *Stdio) OnCodeRestart() {
113 | main := self.Main()
114 | if main.Opt.Verb {
115 | log.Println(`received ^R, restarting`)
116 | }
117 | main.Restart()
118 | }
119 |
120 | func (self *Stdio) OnCodeStop() {
121 | self.OnCodeSig(CODE_STOP, syscall.SIGTERM, `^T`)
122 | }
123 |
124 | func (self *Stdio) OnByteAny(char byte) {
125 | if self.Main().GetEchoMode() == EchoModeGow {
126 | gg.Nop2(writeByte(os.Stdout, char))
127 | }
128 | }
129 |
130 | func (self *Stdio) OnCodeSig(code byte, sig syscall.Signal, desc string) {
131 | main := self.Main()
132 |
133 | if self.IsCodeRepeated(code) {
134 | log.Println(`received ` + desc + desc + `, shutting down`)
135 | main.Kill(sig)
136 | return
137 | }
138 |
139 | if main.Opt.Verb {
140 | log.Println(`broadcasting ` + desc + ` to subprocesses; repeat within ` + DoubleInputDelay.String() + ` to kill gow`)
141 | }
142 | main.Cmd.Broadcast(sig)
143 | }
144 |
145 | func (self *Stdio) IsCodeRepeated(char byte) bool {
146 | return self.LastChar == char && time.Since(self.LastInst) < DoubleInputDelay
147 | }
148 |
--------------------------------------------------------------------------------
/gow_misc.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "io"
5 | "os"
6 | "path/filepath"
7 | "regexp"
8 | "strings"
9 | "syscall"
10 |
11 | "github.com/mitranim/gg"
12 | )
13 |
14 | const (
15 | // These names reflect standard naming and meaning.
16 | // Reference: https://en.wikipedia.org/wiki/Ascii.
17 | // See our re-interpretation below.
18 | ASCII_END_OF_TEXT = 3 // ^C
19 | ASCII_BACKSPACE = 8 // ^H
20 | ASCII_FILE_SEPARATOR = 28 // ^\
21 | ASCII_DEVICE_CONTROL_2 = 18 // ^R
22 | ASCII_DEVICE_CONTROL_4 = 20 // ^T
23 | ASCII_UNIT_SEPARATOR = 31 // ^- or ^?
24 | ASCII_DELETE = 127 // ^H on MacOS
25 |
26 | // These names reflect our re-interpretation of standard codes.
27 | CODE_INTERRUPT = ASCII_END_OF_TEXT
28 | CODE_QUIT = ASCII_FILE_SEPARATOR
29 | CODE_RESTART = ASCII_DEVICE_CONTROL_2
30 | CODE_STOP = ASCII_DEVICE_CONTROL_4
31 | CODE_PRINT_COMMAND = ASCII_UNIT_SEPARATOR
32 | CODE_PRINT_HELP = ASCII_BACKSPACE
33 | CODE_PRINT_HELP_MACOS = ASCII_DELETE
34 | )
35 |
36 | const HOTKEY_HELP = `Control codes / hotkeys:
37 |
38 | 3 ^C Kill subprocess with SIGINT. Repeat within 1s to kill gow.
39 | 18 ^R Kill subprocess with SIGTERM, restart.
40 | 20 ^T Kill subprocess with SIGTERM. Repeat within 1s to kill gow.
41 | 28 ^\ Kill subprocess with SIGQUIT. Repeat within 1s to kill gow.
42 | 31 ^- or ^? Print currently running command.
43 | 8 ^H Print hotkey help.
44 | 127 ^H (MacOS) Print hotkey help.`
45 |
46 | var (
47 | NEWLINE = "\n"
48 | FD_TERM = syscall.Stdin
49 | KILL_SIGS = []syscall.Signal{syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM}
50 | KILL_SIGS_OS = gg.Map(KILL_SIGS, toOsSignal[syscall.Signal])
51 | KILL_SIG_SET = gg.SetOf(KILL_SIGS...)
52 | RE_WORD = regexp.MustCompile(`^\w+$`)
53 | PATH_SEP = string([]rune{os.PathSeparator})
54 |
55 | REP_SINGLE_MULTI = strings.NewReplacer(
56 | `\r\n`, NEWLINE,
57 | `\r`, NEWLINE,
58 | `\n`, NEWLINE,
59 | )
60 | )
61 |
62 | /*
63 | Making `.main` private reduces the chance of accidental cyclic walking by
64 | reflection tools such as pretty printers.
65 | */
66 | type Mained struct{ main *Main }
67 |
68 | func (self *Mained) Init(val *Main) { self.main = val }
69 | func (self *Mained) Main() *Main { return self.main }
70 |
71 | /*
72 | Implemented by `notify.EventInfo`.
73 | Path must be an absolute filesystem path.
74 | */
75 | type FsEvent interface{ Path() string }
76 |
77 | // Implemented by `WatchNotify`.
78 | type Watcher interface {
79 | Init(*Main)
80 | Deinit()
81 | Run()
82 | }
83 |
84 | func commaSplit(val string) []string {
85 | if len(val) <= 0 {
86 | return nil
87 | }
88 | return strings.Split(val, `,`)
89 | }
90 |
91 | func cleanExtension(val string) string {
92 | ext := filepath.Ext(val)
93 | if len(ext) > 0 && ext[0] == '.' {
94 | return ext[1:]
95 | }
96 | return ext
97 | }
98 |
99 | func validateExtension(val string) {
100 | if !RE_WORD.MatchString(val) {
101 | panic(gg.Errf(`invalid extension %q`, val))
102 | }
103 | }
104 |
105 | func toAbsPath(val string) string {
106 | if !filepath.IsAbs(val) {
107 | val = filepath.Join(cwd, val)
108 | }
109 | return filepath.Clean(val)
110 | }
111 |
112 | func toDirPath(val string) string {
113 | if val == `` || strings.HasSuffix(val, PATH_SEP) {
114 | return val
115 | }
116 | return val + PATH_SEP
117 | }
118 |
119 | func toAbsDirPath(val string) string { return toDirPath(toAbsPath(val)) }
120 |
121 | func toOsSignal[A os.Signal](src A) os.Signal { return src }
122 |
123 | func recLog() {
124 | val := recover()
125 | if val != nil {
126 | log.Println(val)
127 | }
128 | }
129 |
130 | func withNewline[A ~string](val A) A {
131 | if gg.HasNewlineSuffix(val) {
132 | return val
133 | }
134 | return val + A(NEWLINE)
135 | }
136 |
137 | func writeByte[A io.Writer](tar A, char byte) (int, error) {
138 | buf := [1]byte{char}
139 | return tar.Write(gg.NoEscUnsafe(&buf)[:])
140 | }
141 |
--------------------------------------------------------------------------------
/gow_main.go:
--------------------------------------------------------------------------------
1 | /*
2 | Go Watch: missing watch mode for the "go" command. Invoked exactly like the
3 | "go" command, but also watches Go files and reruns on changes.
4 | */
5 | package main
6 |
7 | import (
8 | l "log"
9 | "os"
10 | "syscall"
11 |
12 | "github.com/mitranim/gg"
13 | )
14 |
15 | var (
16 | log = l.New(os.Stderr, `[gow] `, 0)
17 | cwd = gg.Cwd()
18 | )
19 |
20 | func main() {
21 | var main Main
22 | defer main.Exit()
23 | defer main.Deinit()
24 | main.Init()
25 | main.Run()
26 | }
27 |
28 | type Main struct {
29 | Opt Opt
30 | Cmd Cmd
31 | Stdio Stdio
32 | Watcher Watcher
33 | Term Term
34 | Sig Sig
35 | ChanRestart gg.Chan[struct{}]
36 | ChanKill gg.Chan[syscall.Signal]
37 | Pid int
38 | }
39 |
40 | func (self *Main) Init() {
41 | self.Opt.Init(os.Args[1:])
42 | self.Term.Init(self)
43 | self.ChanRestart.Init()
44 | self.ChanKill.Init()
45 | self.Cmd.Init(self)
46 | self.Sig.Init(self)
47 | self.WatchInit()
48 | self.Stdio.Init(self)
49 | }
50 |
51 | /*
52 | We MUST call this before exiting because:
53 |
54 | - We modify global OS state: terminal, subprocs.
55 | - OS will NOT auto-cleanup after us.
56 |
57 | Otherwise:
58 |
59 | - Terminal is left in unusable state.
60 | - Subprocs become orphan daemons.
61 |
62 | We MUST call this manually before using `syscall.Kill` or `syscall.Exit` on the
63 | current process. Syscalls terminate the process bypassing Go `defer`.
64 | */
65 | func (self *Main) Deinit() {
66 | self.Stdio.Deinit()
67 | self.Term.Deinit()
68 | self.WatchDeinit()
69 | self.Sig.Deinit()
70 | self.Cmd.Deinit()
71 | }
72 |
73 | func (self *Main) Run() {
74 | if self.Term.IsActive() {
75 | go self.Stdio.Run()
76 | }
77 | go self.Sig.Run()
78 | go self.WatchRun()
79 | self.CmdRun()
80 | }
81 |
82 | func (self *Main) WatchInit() {
83 | wat := new(WatchNotify)
84 | wat.Init(self)
85 | self.Watcher = wat
86 | }
87 |
88 | func (self *Main) WatchDeinit() {
89 | if self.Watcher != nil {
90 | self.Watcher.Deinit()
91 | self.Watcher = nil
92 | }
93 | }
94 |
95 | func (self *Main) WatchRun() {
96 | if self.Watcher != nil {
97 | self.Watcher.Run()
98 | }
99 | }
100 |
101 | func (self *Main) CmdRun() {
102 | if !self.Opt.Postpone {
103 | self.Cmd.Restart()
104 | }
105 |
106 | for {
107 | select {
108 | case <-self.ChanRestart:
109 | self.Opt.TermInter()
110 | self.Cmd.Restart()
111 |
112 | case sig := <-self.ChanKill:
113 | self.kill(sig)
114 | return
115 | }
116 | }
117 | }
118 |
119 | // Must be deferred.
120 | func (self *Main) Exit() {
121 | err := gg.AnyErrTraced(recover())
122 | if err != nil {
123 | self.Opt.LogErr(err)
124 | os.Exit(1)
125 | }
126 | os.Exit(0)
127 | }
128 |
129 | func (self *Main) OnFsEvent(event FsEvent) {
130 | if !self.ShouldRestart(event) {
131 | return
132 | }
133 | if self.Opt.Verb {
134 | log.Println(`restarting on FS event:`, event)
135 | }
136 | self.Restart()
137 | }
138 |
139 | func (self *Main) ShouldRestart(event FsEvent) bool {
140 | return event != nil &&
141 | !(self.Opt.Lazy && self.Cmd.IsRunning()) &&
142 | self.Opt.AllowPath(event.Path())
143 | }
144 |
145 | func (self *Main) Restart() { self.ChanRestart.SendZeroOpt() }
146 |
147 | func (self *Main) Kill(val syscall.Signal) { self.ChanKill.SendOpt(val) }
148 |
149 | // Must be called only on the main goroutine.
150 | func (self *Main) kill(sig syscall.Signal) {
151 | /**
152 | This should terminate any descendant processes, using their default behavior
153 | for the given signal. If any misbehaving processes do not terminate on a
154 | kill signal, this is out of our hands for now. We could use SIGKILL to
155 | ensure termination, but it's unclear if we should.
156 | */
157 | self.Cmd.Broadcast(sig)
158 |
159 | /**
160 | This should restore previous terminal state and un-register our custom signal
161 | handling.
162 | */
163 | self.Deinit()
164 |
165 | /**
166 | Re-send the signal after un-registering our signal handling. If our process is
167 | still running by the time the signal is received, the signal will be handled
168 | by the Go runtime, using the default behavior. Most of the time, this signal
169 | should not be received because after calling this method, we also return
170 | from the main function.
171 | */
172 | gg.Nop1(syscall.Kill(os.Getpid(), sig))
173 | }
174 |
175 | func (self *Main) GetEchoMode() EchoMode {
176 | if self.Term.IsActive() {
177 | return self.Opt.Echo
178 | }
179 | return EchoModeNone
180 | }
181 |
--------------------------------------------------------------------------------
/gow_opt.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | "time"
9 |
10 | "github.com/mitranim/gg"
11 | "golang.org/x/term"
12 | )
13 |
14 | /*
15 | Determines how we spawn the subprocess and handle its stdin.
16 | If `false`, overrides raw mode.
17 | */
18 | var IsTty = term.IsTerminal(int(os.Stdin.Fd()))
19 |
20 | func OptDefault() Opt { return gg.FlagParseTo[Opt](nil) }
21 |
22 | type Opt struct {
23 | Args []string `flag:""`
24 | Help bool `flag:"-h" desc:"Print help and exit."`
25 | Cmd string `flag:"-g" init:"go" desc:"Go tool to use."`
26 | Verb bool `flag:"-v" desc:"Verbose logging."`
27 | ClearHard bool `flag:"-c" desc:"Clear terminal on restart."`
28 | ClearSoft bool `flag:"-s" desc:"Soft-clear terminal, keeping scrollback."`
29 | Raw bool `flag:"-r" desc:"Enable hotkeys (via terminal raw mode)."`
30 | Pre FlagStrMultiline `flag:"-P" desc:"Prefix printed BEFORE each run; multi; supports \\n."`
31 | Suf FlagStrMultiline `flag:"-S" desc:"Suffix printed AFTER each run; multi; supports \\n."`
32 | Trace bool `flag:"-t" desc:"Print error trace on exit. Useful for debugging gow."`
33 | Echo EchoMode `flag:"-re" init:"gow" desc:"Stdin echoing in raw mode. Values: \"\" (none), \"gow\", \"preserve\"."`
34 | Lazy bool `flag:"-l" desc:"Lazy mode: restart only when subprocess is not running."`
35 | Postpone bool `flag:"-p" desc:"Postpone first run until FS event or manual ^R."`
36 | Extensions FlagExtensions `flag:"-e" init:"go,mod" desc:"Extensions to watch; multi."`
37 | WatchDirs FlagWatchDirs `flag:"-w" init:"." desc:"Directories to watch, relative to CWD; multi."`
38 | IgnoreDirs FlagIgnoreDirs `flag:"-i" desc:"Ignored directories, relative to CWD; multi."`
39 | }
40 |
41 | func (self *Opt) Init(src []string) {
42 | err := gg.FlagParseCatch(src, self)
43 | if err != nil {
44 | self.LogErr(err)
45 | gg.Write(log.Writer(), NEWLINE)
46 | self.PrintHelp()
47 | os.Exit(1)
48 | }
49 |
50 | if self.Help || gg.Head(self.Args) == `help` {
51 | self.PrintHelp()
52 | os.Exit(0)
53 | }
54 |
55 | if gg.IsEmpty(self.Args) {
56 | self.PrintHelp()
57 | os.Exit(1)
58 | }
59 |
60 | if self.Raw && !IsTty {
61 | self.Raw = false
62 | if self.Verb {
63 | log.Println(`not in an interactive terminal, disabling raw mode and hotkeys`)
64 | }
65 | }
66 | }
67 |
68 | func (self Opt) PrintHelp() {
69 | gg.FlagFmtDefault.Prefix = "\t"
70 | gg.FlagFmtDefault.Head = false
71 |
72 | gg.Nop2(fmt.Fprintf(log.Writer(), `"gow" is the missing watch mode for the "go" command.
73 | Runs an arbitrary "go" subcommand, watches files, and restarts on changes.
74 |
75 | Usage:
76 |
77 | gow
78 |
79 | Examples:
80 |
81 | gow -c -v test -v -count=1 some_pkg
82 | ↑ gow_flags ↑ cmd ↑․․․․cmd_inputs․․․․↑
83 |
84 | gow run . a b c
85 | gow -c -v -e=go -e=mod -e=html run .
86 | gow -c -v test
87 | gow -c -v install
88 | gow -c -v -w=src -i=.git -i=tar vet
89 |
90 | Flags:
91 |
92 | %v
93 | "Multi" flags can be passed multiple times.
94 | Some also support comma-separated parsing.
95 |
96 | When using "gow" in an interactive terminal, enable hotkey support via "-r".
97 | The flag stands for "raw mode". Avoid this in non-interactive environments,
98 | or when running multiple "gow" concurrently in the same terminal. Examples:
99 |
100 | gow -v -r vet
101 | gow -v -r run .
102 |
103 | %v
104 | `, gg.FlagHelp[Opt](), HOTKEY_HELP))
105 | }
106 |
107 | func (self Opt) LogErr(err error) {
108 | if err != nil {
109 | if self.Trace {
110 | log.Printf(`%+v`, err)
111 | } else {
112 | log.Println(err)
113 | }
114 | }
115 | }
116 |
117 | func (self Opt) LogCmdExit(err error, dur time.Duration) {
118 | if err == nil {
119 | if self.Verb {
120 | log.Printf(`subprocess done in %v`, dur)
121 | }
122 | return
123 | }
124 |
125 | if self.Verb || !self.ShouldSkipErr(err) {
126 | log.Printf(`subprocess error after %v: %v`, dur, err)
127 | }
128 | }
129 |
130 | /*
131 | `go run` reports exit code to stderr. `go test` reports test failures.
132 | In those cases, we suppress the "exit code" error to avoid redundancy.
133 | */
134 | func (self Opt) ShouldSkipErr(err error) bool {
135 | head := gg.Head(self.Args)
136 | return (head == `run` || head == `test`) && errors.As(err, new(*exec.ExitError))
137 | }
138 |
139 | func (self Opt) TermPre() { self.Pre.Dump(log.Writer()) }
140 |
141 | func (self Opt) TermSuf() { self.Suf.Dump(log.Writer()) }
142 |
143 | // TODO more descriptive name.
144 | func (self Opt) TermInter() {
145 | self.TermPre()
146 | self.TermClear()
147 | }
148 |
149 | func (self Opt) TermClear() {
150 | if self.ClearHard {
151 | gg.Write(os.Stdout, TermEscClearHard)
152 | } else if self.ClearSoft {
153 | gg.Write(os.Stdout, TermEscClearSoft)
154 | }
155 | }
156 |
157 | func (self Opt) AllowPath(path string) bool {
158 | return self.Extensions.Allow(path) && self.IgnoreDirs.Allow(path)
159 | }
160 |
--------------------------------------------------------------------------------
/gow_term.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/mitranim/gg"
5 | "golang.org/x/sys/unix"
6 | )
7 |
8 | // https://en.wikipedia.org/wiki/ANSI_escape_code
9 | const (
10 | // Standard terminal escape sequence. Same as "\x1b" or "\033".
11 | TermEsc = string(rune(27))
12 |
13 | // Control Sequence Introducer. Used for other codes.
14 | TermEscCsi = TermEsc + `[`
15 |
16 | // Update cursor position to first row, first column.
17 | TermEscCup = TermEscCsi + `1;1H`
18 |
19 | // Supposed to clear the screen without clearing the scrollback, aka soft
20 | // clear. Seems insufficient on its own, at least in some terminals.
21 | TermEscErase2 = TermEscCsi + `2J`
22 |
23 | // Supposed to clear the screen and the scrollback, aka hard clear. Seems
24 | // insufficient on its own, at least in some terminals.
25 | TermEscErase3 = TermEscCsi + `3J`
26 |
27 | // Supposed to reset the terminal to initial state, aka super hard clear.
28 | // Seems insufficient on its own, at least in some terminals.
29 | TermEscReset = TermEsc + `c`
30 |
31 | // Clear screen without clearing scrollback.
32 | TermEscClearSoft = TermEscCup + TermEscErase2
33 |
34 | // Clear screen AND scrollback.
35 | TermEscClearHard = TermEscCup + TermEscReset + TermEscErase3
36 | )
37 |
38 | /*
39 | By default, any regular terminal uses what's known as "cooked mode", where the
40 | terminal buffers lines before sending them to the foreground process, and
41 | interprets ASCII control codes on stdin by sending the corresponding OS signals
42 | to the process.
43 |
44 | We switch the terminal into "raw mode", where it mostly forwards inputs to our
45 | process's stdin as-is, and interprets fewer special ASCII codes. This allows to
46 | support special key combinations such as ^R for restarting a subprocess.
47 | Unfortunately, this also makes us responsible for interpreting the rest of the
48 | ASCII control codes. It's possible that our support for those is incomplete.
49 |
50 | The terminal state is shared between all super- and sub-processes. Changes
51 | persist even after our process terminates. We endeavor to restore the previous
52 | state before exiting.
53 |
54 | References:
55 |
56 | https://en.wikibooks.org/wiki/Serial_Programming/termios
57 |
58 | man termios
59 | */
60 | type Term struct{ State *unix.Termios }
61 |
62 | func (self Term) IsActive() bool { return self.State != nil }
63 |
64 | func (self *Term) Deinit() {
65 | state := self.State
66 | if state == nil {
67 | return
68 | }
69 | self.State = nil
70 | gg.Nop1(unix.IoctlSetTermios(FD_TERM, ioctlWriteTermios, state))
71 | }
72 |
73 | /*
74 | Goal:
75 |
76 | - Get old terminal state.
77 | - Set new terminal state.
78 | - Remember old terminal state to restore it when exiting.
79 |
80 | Known issue: race condition between multiple concurrent `gow` processes in the
81 | same terminal tab. This is common when running `gow` recipes in a makefile.
82 | Our own `makefile` provides an example of how to avoid using multiple raw modes
83 | concurrently.
84 | */
85 | func (self *Term) Init(main *Main) {
86 | self.Deinit()
87 | if !main.Opt.Raw {
88 | return
89 | }
90 |
91 | prev, err := unix.IoctlGetTermios(FD_TERM, ioctlReadTermios)
92 | if err != nil {
93 | log.Println(`unable to read terminal state:`, err)
94 | return
95 | }
96 | next := *prev
97 |
98 | /**
99 | In raw mode, we support multiple modes of echoing stdin to stdout. Each
100 | approach has different issues.
101 |
102 | Most terminals, in addition to echoing non-special characters, also have
103 | special support for various ASCII control codes, printing them in the
104 | so-called "caret notation". Codes that send signals are cosmetically printed
105 | as hotkeys such as `^C`, `^R`, and so on. The delete code (127) should cause
106 | the terminal to delete one character before the caret, moving the caret. At
107 | the time of writing, the built-in MacOS terminal doesn't properly handle the
108 | delete character when operating in raw mode, printing it in the caret
109 | notation `^?`, which is a jarring and useless change from non-raw mode.
110 |
111 | The workaround we use by default (mode `EchoModeGow`) is to suppress default
112 | echoing in raw mode, and echo by ourselves in the `Stdio` type. We don't
113 | print the caret notation at all. This works fine for most characters, but at
114 | least in some terminals, deletion via the delete character (see above)
115 | doesn't seem to work when we echo the character as-is.
116 |
117 | Other modes allow to suppress echoing completely or fall back on the buggy
118 | terminal default.
119 | */
120 | switch main.Opt.Echo {
121 | case EchoModeNone:
122 | next.Lflag &^= unix.ECHO
123 |
124 | case EchoModeGow:
125 | // We suppress the default echoing here and replicate it ourselves in
126 | // `Stdio.OnByteAny`.
127 | next.Lflag &^= unix.ECHO
128 |
129 | case EchoModePreserve:
130 | // The point of this mode is to preserve the previous echo mode of the
131 | // terminal, whatever it was.
132 |
133 | default:
134 | panic(main.Opt.Echo.errInvalid())
135 | }
136 |
137 | // Don't buffer lines.
138 | next.Lflag &^= unix.ICANON
139 |
140 | // No signals.
141 | next.Lflag &^= unix.ISIG
142 |
143 | // Seems unnecessary on my system. Might be needed elsewhere.
144 | // next.Cflag |= unix.CS8
145 | // next.Cc[unix.VMIN] = 1
146 | // next.Cc[unix.VTIME] = 0
147 |
148 | err = unix.IoctlSetTermios(FD_TERM, ioctlWriteTermios, &next)
149 | if err != nil {
150 | log.Println(`unable to switch terminal to raw mode:`, err)
151 | return
152 | }
153 |
154 | self.State = prev
155 | }
156 |
--------------------------------------------------------------------------------
/gow_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | "path/filepath"
9 | "testing"
10 |
11 | "github.com/mitranim/gg"
12 | "github.com/mitranim/gg/gtest"
13 | )
14 |
15 | var testIgnoredPath = filepath.Join(cwd, `ignore3/file.ext3`)
16 |
17 | var testIgnoredEvent = FsEvent(TestFsEvent(testIgnoredPath))
18 |
19 | var testOpt = func() (tar Opt) {
20 | tar.Init([]string{
21 | `-e=ext1`,
22 | `-e=ext2`,
23 | `-e=ext3`,
24 | `-i=./ignore1`,
25 | `-i=ignore2`,
26 | `-i=ignore3`,
27 | `some_command`,
28 | })
29 | return
30 | }()
31 |
32 | var testMain = Main{Opt: testOpt}
33 |
34 | type TestFsEvent string
35 |
36 | func (self TestFsEvent) Path() string { return string(self) }
37 |
38 | func TestFlagExtensions(t *testing.T) {
39 | defer gtest.Catch(t)
40 |
41 | opt := OptDefault()
42 | gtest.Equal(opt.Extensions, FlagExtensions{`go`, `mod`})
43 |
44 | {
45 | var tar FlagExtensions
46 | gtest.NoErr(tar.Parse(`one,two,three`))
47 | gtest.Equal(tar, FlagExtensions{`one`, `two`, `three`})
48 | }
49 |
50 | {
51 | var tar FlagExtensions
52 | gtest.NoErr(tar.Parse(`one`))
53 | gtest.NoErr(tar.Parse(`two`))
54 | gtest.NoErr(tar.Parse(`three`))
55 | gtest.Equal(tar, FlagExtensions{`one`, `two`, `three`})
56 | }
57 | }
58 |
59 | func testIgnore[Ignore interface {
60 | ~[]string
61 | Norm()
62 | Ignore(string) bool
63 | }](path string, ignore Ignore, exp bool) {
64 | // Even though we invoke this on a value type, this works because the method
65 | // mutates the underlying array, not the slice header itself.
66 | ignore.Norm()
67 | path = filepath.Join(cwd, path)
68 | msg := fmt.Sprintf(`ignore: %q; path: %q`, ignore, path)
69 |
70 | if exp {
71 | gtest.True(ignore.Ignore(path), msg)
72 | } else {
73 | gtest.False(ignore.Ignore(path), msg)
74 | }
75 | }
76 |
77 | func TestFlagIgnoreDirs_Ignore(t *testing.T) {
78 | defer gtest.Catch(t)
79 |
80 | type Ignore = FlagIgnoreDirs
81 |
82 | {
83 | testIgnore(`one.go`, Ignore{}, false)
84 | testIgnore(`one.go`, Ignore{`.`}, true)
85 | testIgnore(`one.go`, Ignore{`*`}, false)
86 | testIgnore(`one.go`, Ignore{`.`, `two`}, true)
87 | testIgnore(`one.go`, Ignore{`*`, `two`}, false)
88 | testIgnore(`one.go`, Ignore{`./*`}, false)
89 | testIgnore(`one.go`, Ignore{`two`}, false)
90 | testIgnore(`one.go`, Ignore{`one.go`}, false)
91 | }
92 |
93 | {
94 | testIgnore(`one/two.go`, Ignore{}, false)
95 | testIgnore(`one/two.go`, Ignore{`one/two.go`}, false)
96 |
97 | testIgnore(`one/two.go`, Ignore{`.`}, true)
98 | testIgnore(`one/two.go`, Ignore{`./.`}, true)
99 | testIgnore(`one/two.go`, Ignore{`././.`}, true)
100 |
101 | testIgnore(`one/two.go`, Ignore{`*`}, false)
102 | testIgnore(`one/two.go`, Ignore{`./*`}, false)
103 | testIgnore(`one/two.go`, Ignore{`*/*`}, false)
104 | testIgnore(`one/two.go`, Ignore{`./*/*`}, false)
105 |
106 | testIgnore(`one/two.go`, Ignore{`one`}, true)
107 | testIgnore(`one/two.go`, Ignore{`./one`}, true)
108 |
109 | testIgnore(`one/two.go`, Ignore{`one/.`}, true)
110 | testIgnore(`one/two.go`, Ignore{`./one/.`}, true)
111 |
112 | testIgnore(`one/two.go`, Ignore{`one/*`}, false)
113 | testIgnore(`one/two.go`, Ignore{`./one/*`}, false)
114 |
115 | testIgnore(`one/two.go`, Ignore{`one/two`}, false)
116 | testIgnore(`one/two.go`, Ignore{`./one/two`}, false)
117 |
118 | testIgnore(`one/two.go`, Ignore{`one/two/.`}, false)
119 | testIgnore(`one/two.go`, Ignore{`./one/two/.`}, false)
120 |
121 | testIgnore(`one/two.go`, Ignore{`one/two/*`}, false)
122 | testIgnore(`one/two.go`, Ignore{`./one/two/*`}, false)
123 |
124 | testIgnore(`one/two.go`, Ignore{`two`}, false)
125 | testIgnore(`one/two.go`, Ignore{`one`, `three`}, true)
126 | testIgnore(`one/two.go`, Ignore{`three`, `one`}, true)
127 | }
128 |
129 | {
130 | testIgnore(`.one/two.go`, Ignore{}, false)
131 | testIgnore(`.one/two.go`, Ignore{`.one`}, true)
132 | testIgnore(`.one/two.go`, Ignore{`./.one`}, true)
133 | testIgnore(`.one/two.go`, Ignore{`.one/.`}, true)
134 | testIgnore(`.one/two.go`, Ignore{`.one/*`}, false)
135 | testIgnore(`.one/two.go`, Ignore{`three`}, false)
136 | }
137 |
138 | {
139 | testIgnore(`.one/two/three.go`, Ignore{`.one`}, true)
140 | testIgnore(`.one/two/three.go`, Ignore{`.one/.`}, true)
141 | testIgnore(`.one/two/three.go`, Ignore{`.one/*`}, false)
142 | testIgnore(`.one/two/three.go`, Ignore{`.one/two`}, true)
143 | testIgnore(`.one/two/three.go`, Ignore{`.one/two/.`}, true)
144 | testIgnore(`.one/two/three.go`, Ignore{`.one/two/*`}, false)
145 | testIgnore(`.one/two/three.go`, Ignore{`.one/three`}, false)
146 | }
147 | }
148 |
149 | func BenchmarkOpt_AllowPath(b *testing.B) {
150 | gtest.False(testOpt.AllowPath(testIgnoredPath))
151 | b.ResetTimer()
152 |
153 | for ind := 0; ind < b.N; ind++ {
154 | testOpt.AllowPath(testIgnoredPath)
155 | }
156 | }
157 |
158 | func BenchmarkMain_ShouldRestart(b *testing.B) {
159 | gtest.False(testMain.ShouldRestart(testIgnoredEvent))
160 | b.ResetTimer()
161 |
162 | for ind := 0; ind < b.N; ind++ {
163 | testMain.ShouldRestart(testIgnoredEvent)
164 | }
165 | }
166 |
167 | func Test_PsOutToSubPids(t *testing.T) {
168 | defer gtest.Catch(t)
169 |
170 | const SRC = `
171 | PID PPID
172 | 1 0
173 | 83 1
174 | 85 1
175 | 91 1
176 | 1634 1
177 | 1909 1
178 | 1951 3967
179 | 1982 3967
180 | PID PPID
181 | 2764 1
182 | 3967 1
183 | 3971 1
184 | 3975 3967
185 | 3976 3967
186 | 3977 3967
187 | 4000 3967
188 | 4008 3967
189 | 4009 3967
190 | 4060 1
191 | 4098 1
192 | 4801 4008
193 | 5125 1
194 | 5627 1
195 | 5682 4009
196 | 5683 1
197 | 10 20 30 40 (should be ignored)
198 | `
199 |
200 | ppidToPids := procPairsToIndex(psOutToProcPairs(SRC))
201 |
202 | gtest.Equal(ppidToPids, map[int][]int{
203 | 0: {1},
204 | 1: {83, 85, 91, 1634, 1909, 2764, 3967, 3971, 4060, 4098, 5125, 5627, 5683},
205 | 3967: {1951, 1982, 3975, 3976, 3977, 4000, 4008, 4009},
206 | 4008: {4801},
207 | 4009: {5682},
208 | })
209 |
210 | const topPid = 3967
211 | const skipPid = 4008
212 |
213 | descs := procIndexToDescs(ppidToPids, topPid, skipPid)
214 | gtest.Equal(PsOutToSubPids(SRC, topPid, skipPid), descs)
215 |
216 | gtest.Equal(
217 | descs,
218 | []int{5682, 4009, 4000, 3977, 3976, 3975, 1982, 1951},
219 | )
220 | }
221 |
222 | func TestSubPids(t *testing.T) {
223 | defer gtest.Catch(t)
224 |
225 | /**
226 | Our process doesn't have any children, so we have to spawn one
227 | for testing purposes.
228 | */
229 |
230 | ctx, cancel := context.WithCancel(context.Background())
231 | t.Cleanup(cancel)
232 |
233 | cmd := exec.CommandContext(ctx, `sleep`, `1`)
234 | cmd.Start()
235 |
236 | pids := gg.Try1(SubPids(os.Getpid(), true))
237 | gtest.Len(pids, 1)
238 | }
239 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | ## Overview
2 |
3 | **Go** **W**atch: missing watch mode for the `go` command. It's invoked exactly like `go`, but also watches Go files and reruns on changes.
4 |
5 | Currently requires Unix (MacOS, Linux, BSD). On Windows, runs under WSL.
6 |
7 | ## TOC
8 |
9 | * [Overview](#overview)
10 | * [Why](#why)
11 | * [Installation](#installation)
12 | * [Usage](#usage)
13 | * [Hotkeys](#hotkeys)
14 | * [Configuration](#configuration)
15 | * [Scripting](#scripting)
16 | * [Gotchas](#gotchas)
17 | * [Watching Templates](#watching-templates)
18 | * [Alternatives](#alternatives)
19 | * [License](#license)
20 | * [Misc](#misc)
21 |
22 | ## Why
23 |
24 | Why not other runners, general-purpose watchers, etc:
25 |
26 | * Go-specific, easy to remember.
27 | * Has hotkeys, such as `ctrl+r` to restart! (Opt-in via `-r`.)
28 | * Ignores non-Go files by default.
29 | * Better watcher: recursive, no delays, no polling; uses https://github.com/rjeczalik/notify.
30 | * Silent by default. (Opt-in logging via `-v`.)
31 | * No garbage files.
32 | * Properly clears the terminal on restart. (Opt-in via `-c`.)
33 | * Does not leak subprocesses.
34 | * Minimal dependencies.
35 |
36 | ## Installation
37 |
38 | Make sure you have Go installed, then run this:
39 |
40 | ```sh
41 | go install github.com/mitranim/gow@latest
42 | ```
43 |
44 | This should download the source and compile the executable into `$GOPATH/bin/gow`. Make sure `$GOPATH/bin` is in your `$PATH` so the shell can discover the `gow` command. For example, my `~/.profile` contains this:
45 |
46 | ```sh
47 | export GOPATH="$HOME/go"
48 | export PATH="$GOPATH/bin:$PATH"
49 | ```
50 |
51 | Alternatively, you can run the executable using the full path. At the time of writing, `~/go` is the default `$GOPATH` for Go installations. Some systems may have a different one.
52 |
53 | ```sh
54 | ~/go/bin/gow
55 | ```
56 |
57 | On MacOS, if installation fails with dylib-related errors, you may need to run `xcode-select --install` or install Xcode. This is caused by `gow`'s dependencies, which depend on C. See [#15](https://github.com/mitranim/gow/issues/15).
58 |
59 | ## Usage
60 |
61 | The first argument to `gow`, after the flags, can be any Go subcommand: `build`, `install`, `tool`, you name it.
62 |
63 | ```sh
64 | # Start and restart on change
65 | gow run .
66 |
67 | # Pass args to the program
68 | gow run . arg0 arg1 ...
69 |
70 | # Run subdirectory
71 | gow run ./subdir
72 |
73 | # Vet and re-vet on change; verbose mode is recommended
74 | gow -v vet
75 |
76 | # Clear terminal on restart
77 | gow -c run .
78 |
79 | # Specify file extension to watch
80 | gow -e=go,mod,html run .
81 |
82 | # Enable hotkey support
83 | gow -v -r vet
84 |
85 | # Help
86 | gow -h
87 | ```
88 |
89 | ## Hotkeys
90 |
91 | The flag `-r` enables hotkey support. Should be used in interactive terminals at the top level, but should be avoided in non-interactive environments (e.g. containers) and when running multiple `gow` concurrently (e.g. orchestrated via Make).
92 |
93 | This mode is only available in a TTY. In this mode, the subprocess is _not_ considered to be in a TTY, and cannot read stdin. Avoid it for programs which need to be interactive by reading user input from stdin.
94 |
95 | Supported control codes with commonly associated hotkeys. Exact keys may vary between terminal apps. For example, `^-` in MacOS Terminal vs `^?` in iTerm2.
96 |
97 | ```
98 | 3 ^C Kill subprocess with SIGINT.
99 | 18 ^R Kill subprocess with SIGTERM, restart.
100 | 20 ^T Kill subprocess with SIGTERM.
101 | 28 ^\ Kill subprocess with SIGQUIT.
102 | 31 ^- or ^? Print currently running command.
103 | 8 ^H Print help.
104 | 127 ^H (MacOS) Print help.
105 | ```
106 |
107 | In slightly more technical terms, `gow` switches the terminal into [raw mode](https://en.wikibooks.org/wiki/Serial_Programming/termios), reads from stdin, interprets some ASCII control codes, and forwards the other input to the subprocess as-is. In raw mode, pressing one of these hotkeys causes a terminal to write the corresponding byte to stdin, which is then interpreted by `gow`.
108 |
109 | See the example [`makefile`](makefile) for how to detect if we're about to run one or more `gow`, and enabling raw mode only when safe.
110 |
111 | ## Configuration
112 |
113 | At present, `gow` _does not_ support config files. All configuration is done through CLI flags. This is suitable for small, simple projects. Larger projects typically use a build tool such as Make, which is also sufficient for managing the configuration of `gow`. See the example [`makefile`](makefile).
114 |
115 | ## Scripting
116 |
117 | `gow` invokes an arbitrary executable; by default it invokes `go` which should be installed globally. For some advanced use cases, you may need a custom script. For example, if you want `gow` to run `go generate` before any other `go` operation, create a local shell script `go.sh`:
118 |
119 | ```sh
120 | touch go.sh
121 | chmod +x go.sh
122 | ```
123 |
124 | ...with the following content:
125 |
126 | ```sh
127 | #!/bin/sh
128 |
129 | go generate &&
130 | go $@
131 | ```
132 |
133 | To invoke it, use `-g` when running `gow`:
134 |
135 | ```sh
136 | gow -g=./go.sh -v -c run .
137 | ```
138 |
139 | Alternatively, instead of creating script files, you can write recipes in a makefile; see [Configuration](#configuration) and the example [`makefile`](makefile).
140 |
141 | ## Gotchas
142 |
143 | Enabling hotkeys via `-r` involves switching the terminal into "raw mode"; see [1](https://en.wikibooks.org/wiki/Serial_Programming/termios). As a result, this is _only_ viable when:
144 |
145 | * You run `gow` in an interactive terminal.
146 | * There is only one instance of `gow` in this terminal tab.
147 | * There are no other processes in this tab that use raw mode; examples:
148 | * Editors such as `nano`/`vim`/`emacs`.
149 | * Another `gow` with `-r`.
150 | * The subprocess does not need to read from stdin.
151 |
152 | In Docker, or in any other non-interactive environment, `-r` may produce errors related to terminal state. Examples:
153 |
154 | ```
155 | > unable to read terminal state
156 | > inappropriate ioctl for device
157 | > operation not supported by device
158 | ```
159 |
160 | There should be only one `gow -r` per terminal tab. When running multiple `gow` processes in one terminal tab, most should be `gow -r=false`. `gow` processes do not coordinate. If several are attempting to modify the terminal state (from cooked mode to raw mode, then restore), due to a race condition, they may end up "restoring" the wrong state, leaving the terminal in the raw mode at the end.
161 |
162 | See [`makefile`](makefile), particularly the variable `GOW_HOTKEYS`, for how to detect concurrent execution of multiple tasks, and avoid enabling hotkeys / raw mode.
163 |
164 | When `gow` runs in raw mode, the subprocess's stdin is always empty, immediately closed (EOF), and is not a TTY.
165 |
166 | ## Watching Templates
167 |
168 | Many Go programs, such as servers, include template files, and want to recompile those templates on change.
169 |
170 | Easy but slow way: use `gow -e`.
171 |
172 | ```sh
173 | gow -e=go,mod,html run .
174 | ```
175 |
176 | This restarts your entire app on change to any `.html` file in the current directory or sub-directories. Beware: if the app also generates files with the same extensions, this could cause an infinite restart loop. Ignore any output directories with `-i`:
177 |
178 | ```sh
179 | gow -e=go,mod,html -i=target run .
180 | ```
181 |
182 | A smarter approach would be to watch the template files from _inside_ the app and recompile them without restarting the entire app. This is out of scope for `gow`.
183 |
184 | Finally, you can use a pure-Go rendering system such as [github.com/mitranim/gax](https://github.com/mitranim/gax).
185 |
186 | ## Alternatives
187 |
188 | For general purpose file watching, consider these excellent tools:
189 | * https://github.com/mattgreen/watchexec
190 | * https://github.com/emcrisostomo/fswatch
191 |
192 | ## TODO
193 |
194 | * Use GitHub Actions to automatically build executables. Research how to publish to various package managers.
195 | * Investigate switching from `golang.org/x/sys/unix` to `golang.org/x/term`, which is higher-level and supports Windows. This may allow `gow` to work on Windows.
196 | * Tried, seems to break stdio.
197 | * Consider having a flag that takes a string and writes that string to the subprocess stdin on each subprocess start.
198 | * Or better: read our own stdin on startup, buffer it, and pass it to the subproc every time.
199 | * Consider using `https://github.com/creack/pty` to allocate a pseudo-terminal for the subprocess when raw mode is enabled _and_ we're in a TTY, then forward any unhandled stdin to the subprocess.
200 | * Tried, didn't work.
201 | * Consider intercepting interrupt in non-raw mode, similar to raw mode.
202 | * Forgot why, probably unnecessary.
203 | * Add a hotkey that parses subprocess output, looking for what looks like file paths with optional rows and columns, and opens the first, then the next, and so on.
204 |
205 | ## License
206 |
207 | https://unlicense.org
208 |
--------------------------------------------------------------------------------