From 2eb61b0a2867dc8d17bd9947b7bf725ad7e88fd5 Mon Sep 17 00:00:00 2001 From: arivard Date: Wed, 28 Jan 2026 01:34:13 +0000 Subject: [PATCH 1/2] feat: add recursive directory watching and shell tab completion - Watch now recursively watches all subdirectories when given a directory path - Watch properly handles individual files by watching them directly - New directories created after startup are automatically added to the watcher - Added --completion flag for generating bash, zsh, and fish completion scripts - Task name parsing correctly identifies task definitions in tasks.yaml Branch-Creation-Time: 2026-01-28T01:33:20+0000 --- internal/run.go | 40 ++++++++++++++++++- main.go | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/internal/run.go b/internal/run.go index 748aa3f..d49bbe7 100644 --- a/internal/run.go +++ b/internal/run.go @@ -101,8 +101,32 @@ func RunSubgraph(ctx context.Context, cancel context.CancelFunc, port int, openB return fmt.Errorf("failed to create watcher: %w", err) } for _, source := range node.Task.Watch { - if err := watcher.Add(filepath.Join(node.Task.WorkingDir, source)); err != nil { - return fmt.Errorf("failed to watch %q: %w", source, err) + watchPath := filepath.Join(node.Task.WorkingDir, source) + info, err := os.Stat(watchPath) + if err != nil { + return fmt.Errorf("failed to stat %q: %w", source, err) + } + if info.IsDir() { + // Walk the directory tree to add the directory and all subdirectories + err := filepath.Walk(watchPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if err := watcher.Add(path); err != nil { + return fmt.Errorf("failed to watch %q: %w", path, err) + } + } + return nil + }) + if err != nil { + return fmt.Errorf("failed to walk %q: %w", source, err) + } + } else { + // It's a file, watch it directly + if err := watcher.Add(watchPath); err != nil { + return fmt.Errorf("failed to watch %q: %w", source, err) + } } } defer watcher.Close() @@ -115,6 +139,7 @@ func RunSubgraph(ctx context.Context, cancel context.CancelFunc, port int, openB case <-ctx.Done(): return case event := <-watcher.Events: + // Handle file writes if event.Op&fsnotify.Write == fsnotify.Write { debounceTimer.Stop() debounceTimer = time.AfterFunc(100*time.Millisecond, func() { @@ -122,6 +147,17 @@ func RunSubgraph(ctx context.Context, cancel context.CancelFunc, port int, openB events <- node.Name }) } + // Handle new directories being created - add them to the watcher + if event.Op&fsnotify.Create == fsnotify.Create { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + _ = watcher.Add(event.Name) + } + debounceTimer.Stop() + debounceTimer = time.AfterFunc(100*time.Millisecond, func() { + logger.Printf("[%s] %s created, re-running\n", node.Name, event.Name) + events <- node.Name + }) + } } } }() diff --git a/main.go b/main.go index 71eab3a..5c231c4 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ func main() { port := -1 // -1 means unspecified, 0 means disabled, >0 means specified openBrowser := false rewrite := false + completion := "" flag.BoolVar(&help, "h", false, "print help and exit") flag.BoolVar(&printVersion, "v", false, "print version and exit") @@ -40,6 +41,7 @@ func main() { flag.IntVar(&port, "p", port, "port to start UI on (default 3000, zero disables)") flag.BoolVar(&openBrowser, "b", false, "open the UI in the browser (default false)") flag.BoolVar(&rewrite, "w", false, "rewrite the config file") + flag.StringVar(&completion, "completion", "", "generate shell completion script (bash, zsh, fish)") flag.Parse() taskNames := flag.Args() @@ -54,6 +56,14 @@ func main() { os.Exit(0) } + if completion != "" { + if err := printCompletion(completion, configFile); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + os.Exit(1) + } + os.Exit(0) + } + if err := os.Chdir(workingDir); err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to change to directory %s: %v\n", workingDir, err) os.Exit(1) @@ -123,3 +133,96 @@ func main() { os.Exit(1) } } + +func printCompletion(shell, configFile string) error { + switch shell { + case "bash": + fmt.Print(bashCompletion(configFile)) + case "zsh": + fmt.Print(zshCompletion(configFile)) + case "fish": + fmt.Print(fishCompletion(configFile)) + default: + return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell) + } + return nil +} + +func bashCompletion(configFile string) string { + return fmt.Sprintf(`_kit_completions() { + local cur="${COMP_WORDS[COMP_CWORD]}" + local tasks="" + + if [[ -f "%s" ]]; then + tasks=$(grep -E '^ [a-zA-Z0-9_-]+:\s*$' "%s" 2>/dev/null | sed 's/:.*//' | tr -d ' ' | tr '\n' ' ') + fi + + if [[ "${cur}" == -* ]]; then + COMPREPLY=($(compgen -W "-h -v -C -f -s -p -b -w --completion" -- "${cur}")) + else + COMPREPLY=($(compgen -W "${tasks}" -- "${cur}")) + fi +} + +complete -F _kit_completions kit +`, configFile, configFile) +} + +func zshCompletion(configFile string) string { + return fmt.Sprintf(`#compdef kit + +_kit() { + local -a tasks + local -a opts + + opts=( + '-h[print help and exit]' + '-v[print version and exit]' + '-C[working directory]:directory:_files -/' + '-f[config file]:file:_files' + '-s[tasks to skip]:tasks:' + '-p[port to start UI on]:port:' + '-b[open the UI in the browser]' + '-w[rewrite the config file]' + '--completion[generate shell completion script]:shell:(bash zsh fish)' + ) + + if [[ -f "%s" ]]; then + tasks=(${(f)"$(grep -E '^ [a-zA-Z0-9_-]+:\s*$' "%s" 2>/dev/null | sed 's/:.*//' | tr -d ' ')"}) + fi + + _arguments -s $opts '*:task:'"(${tasks[*]})" +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_kit" ]; then + _kit +fi + +# register the completion function (requires compinit to have been run) +if [[ -n ${_comps+x} ]]; then + compdef _kit kit +fi +`, configFile, configFile) +} + +func fishCompletion(configFile string) string { + return fmt.Sprintf(`function __fish_kit_tasks + if test -f "%s" + grep -E '^ [a-zA-Z0-9_-]+:\s*$' "%s" 2>/dev/null | sed 's/:.*//' | string trim + end +end + +complete -c kit -f +complete -c kit -s h -d 'print help and exit' +complete -c kit -s v -d 'print version and exit' +complete -c kit -s C -d 'working directory' -r -a '(__fish_complete_directories)' +complete -c kit -s f -d 'config file' -r -F +complete -c kit -s s -d 'tasks to skip' +complete -c kit -s p -d 'port to start UI on' +complete -c kit -s b -d 'open the UI in the browser' +complete -c kit -s w -d 'rewrite the config file' +complete -c kit -l completion -d 'generate shell completion script' -r -a 'bash zsh fish' +complete -c kit -n 'not string match -q -- "-*" (commandline -ct)' -a '(__fish_kit_tasks)' +`, configFile, configFile) +} From 36dfd06c62ff1c12745a579facddbb6bdf41655a Mon Sep 17 00:00:00 2001 From: arivard Date: Wed, 28 Jan 2026 01:58:45 +0000 Subject: [PATCH 2/2] test: add tests for recursive watch and shell completion - Add test for watching files in subdirectories - Add tests for bash, zsh, and fish completion output - Add test for task name regex pattern validation - Add test for invalid shell error handling --- internal/run_test.go | 53 +++++++++++++++ internal/testdata/subdir/nested | 1 + main_test.go | 114 ++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 internal/testdata/subdir/nested create mode 100644 main_test.go diff --git a/internal/run_test.go b/internal/run_test.go index 007b5d6..88b51c8 100644 --- a/internal/run_test.go +++ b/internal/run_test.go @@ -311,6 +311,59 @@ sleep 30 }) + t.Run("Restart job by modifying file in watched subdirectory", func(t *testing.T) { + ctx, cancel, logger, buffer := setup(t) + defer cancel() + + // Create subdirectory and file for testing + err := os.MkdirAll("testdata/subdir", 0755) + assert.NoError(t, err) + err = os.WriteFile("testdata/subdir/nested", []byte("initial"), 0644) + assert.NoError(t, err) + + wf := &types.Workflow{ + Tasks: map[string]types.Task{ + "job": {Command: []string{"sh", "-c", ` +set -eu +echo "hello from job" +sleep 30 +`}, Watch: []string{"testdata"}, // Watch the parent directory + }, + }, + } + + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + err := RunSubgraph(ctx, cancel, 0, false, logger, wf, []string{"job"}, nil) + assert.NoError(t, err) + }() + + sleep(t) + + // Modify file in subdirectory - should trigger restart due to recursive watching + err = os.WriteFile("testdata/subdir/nested", []byte("modified"), 0644) + assert.NoError(t, err) + + sleep(t) + + cancel() + wg.Wait() + + // We should see the restart triggered by the nested file change + assert.Contains(t, buffer.String(), "testdata/subdir/nested changed, re-running") + + // Count how many times "hello from job" appears (should be 2) + count := 0 + for _, line := range strings.Split(buffer.String(), "\n") { + if strings.Contains(line, "hello from job") { + count++ + } + } + assert.Equal(t, 2, count) + }) + t.Run("Service without ports is running", func(t *testing.T) { ctx, cancel, logger, buffer := setup(t) defer cancel() diff --git a/internal/testdata/subdir/nested b/internal/testdata/subdir/nested new file mode 100644 index 0000000..d84012f --- /dev/null +++ b/internal/testdata/subdir/nested @@ -0,0 +1 @@ +modified \ No newline at end of file diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..39c642d --- /dev/null +++ b/main_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBashCompletion(t *testing.T) { + output := bashCompletion("tasks.yaml") + + // Should contain the completion function + assert.Contains(t, output, "_kit_completions()") + assert.Contains(t, output, "complete -F _kit_completions kit") + + // Should use correct regex pattern for task names + assert.Contains(t, output, `grep -E '^ [a-zA-Z0-9_-]+:\s*$'`) +} + +func TestZshCompletion(t *testing.T) { + output := zshCompletion("tasks.yaml") + + // Should contain the compdef header + assert.Contains(t, output, "#compdef kit") + + // Should define the _kit function + assert.Contains(t, output, "_kit()") + + // Should use correct regex pattern for task names + assert.Contains(t, output, `grep -E '^ [a-zA-Z0-9_-]+:\s*$'`) + + // Should have guard against running during source + assert.Contains(t, output, `if [ "$funcstack[1]" = "_kit" ]`) + + // Should conditionally register compdef + assert.Contains(t, output, "compdef _kit kit") +} + +func TestFishCompletion(t *testing.T) { + output := fishCompletion("tasks.yaml") + + // Should define the tasks function + assert.Contains(t, output, "__fish_kit_tasks") + + // Should use correct regex pattern for task names + assert.Contains(t, output, `grep -E '^ [a-zA-Z0-9_-]+:\s*$'`) + + // Should have completions for flags + assert.Contains(t, output, "complete -c kit") +} + +func TestPrintCompletionInvalidShell(t *testing.T) { + err := printCompletion("invalid", "tasks.yaml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported shell: invalid") +} + +func TestPrintCompletionValidShells(t *testing.T) { + // These should not error (output goes to stdout) + // We can't easily capture stdout, so just verify no error + for _, shell := range []string{"bash", "zsh", "fish"} { + // Redirect stdout temporarily + old := os.Stdout + _, w, _ := os.Pipe() + os.Stdout = w + + err := printCompletion(shell, "tasks.yaml") + + w.Close() + os.Stdout = old + + assert.NoError(t, err, "shell: %s", shell) + } +} + +func TestTaskNameRegexPattern(t *testing.T) { + // Test the regex pattern we use in completion scripts + // This simulates what the grep command does + + testYaml := `env: + AWS_ENDPOINT_URL: http://localhost:4566 + +tasks: + clean: + sh: | + echo "cleaning" + build-app: + command: go build . + watch: src + my_task_123: + sh: echo "test" +` + + // Extract task names using the same pattern as our completion + lines := strings.Split(testYaml, "\n") + var tasks []string + for _, line := range lines { + // Match: starts with exactly 2 spaces, alphanumeric/hyphen/underscore, colon, end of line + if len(line) >= 3 && line[0] == ' ' && line[1] == ' ' && line[2] != ' ' { + // Check if line ends with : (possibly with trailing whitespace) + trimmed := strings.TrimRight(line, " \t") + if strings.HasSuffix(trimmed, ":") && !strings.Contains(trimmed, ": ") { + name := strings.TrimSpace(strings.TrimSuffix(trimmed, ":")) + tasks = append(tasks, name) + } + } + } + + assert.Equal(t, []string{"clean", "build-app", "my_task_123"}, tasks) + // Should NOT include AWS_ENDPOINT_URL (has value after colon) + // Should NOT include sh, command, watch (4-space indent) +}