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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions internal/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -115,13 +139,25 @@ 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() {
logger.Printf("[%s] %s changed, re-running\n", node.Name, event.Name)
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
})
}
}
}
}()
Expand Down
53 changes: 53 additions & 0 deletions internal/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions internal/testdata/subdir/nested
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
modified
103 changes: 103 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
114 changes: 114 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}