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
6 changes: 5 additions & 1 deletion cmd/autosync.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ var (
)

// mutatingCommands lists commands that modify local data and should trigger auto-sync.
// A top-level name also covers its subcommands. A full path such as "comments add"
// covers just that subcommand, for a parent whose own run only reads.
var mutatingCommands = map[string]bool{
"create": true,
"update": true,
Expand All @@ -56,7 +58,6 @@ var mutatingCommands = map[string]bool{
"link": true,
"unlink": true,
"comment": true,
"comments": true,
"undo": true,
"import": true,
"init": true,
Expand All @@ -68,6 +69,9 @@ var mutatingCommands = map[string]bool{
"task": true,
"ws": true,
"monitor": true,

// Not "comments": `td comments <id>` only lists.
"comments add": true,
}

// isMutatingCommand checks if the given command name triggers auto-sync.
Expand Down
64 changes: 62 additions & 2 deletions cmd/autosync_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"strings"
"sync/atomic"
"testing"
"time"
Expand All @@ -10,22 +11,81 @@ import (

func TestIsMutatingCommand(t *testing.T) {
// Commands that should trigger auto-sync
mutating := []string{"create", "update", "delete", "start", "close", "log", "handoff", "board", "dep", "ws", "comments", "monitor"}
mutating := []string{"create", "update", "delete", "start", "close", "log", "handoff", "board", "dep", "ws", "comments add", "monitor"}
for _, name := range mutating {
if !isMutatingCommand(name) {
t.Errorf("expected %q to be mutating", name)
}
}

// Commands that should NOT trigger auto-sync
readOnly := []string{"list", "show", "search", "query", "sync", "auth", "status", "info", "version", "help", "doctor"}
readOnly := []string{"list", "show", "search", "query", "sync", "auth", "status", "info", "version", "help", "doctor", "comments"}
for _, name := range readOnly {
if isMutatingCommand(name) {
t.Errorf("expected %q to NOT be mutating", name)
}
}
}

func TestIsMutatingInvocation(t *testing.T) {
tests := []struct {
args []string
want bool
}{
{[]string{"comments", "td-1"}, false},
{[]string{"comments", "add", "td-1", "text"}, true},
{[]string{"comment", "td-1", "text"}, true},
{[]string{"board", "list"}, true},
{[]string{"list"}, false},
}
for _, tt := range tests {
t.Run(strings.Join(tt.args, " "), func(t *testing.T) {
cmd, _, err := rootCmd.Find(tt.args)
if err != nil {
t.Fatalf("find %v: %v", tt.args, err)
}
if got := isMutatingInvocation(cmd); got != tt.want {
t.Errorf("isMutatingInvocation(%q) = %v, want %v", cmd.CommandPath(), got, tt.want)
}
})
}
}

func TestMutationHookSkipsCommentsListing(t *testing.T) {
t.Setenv("TD_AUTH_KEY", "test-key")
dir := setupSyncStateDir(t, "proj-test", false)
saveAndRestoreGlobals(t)
baseDirOverride = &dir

origHooks := syncFeatureHooks
t.Cleanup(func() { syncFeatureHooks = origHooks })
calls := 0
hooks := origHooks
hooks.OnAfterMutation = func() { calls++ }
syncFeatureHooks = hooks

tests := []struct {
args []string
want int
}{
{[]string{"comments", "td-1"}, 0},
{[]string{"comments", "add", "td-1", "text"}, 1},
}
for _, tt := range tests {
t.Run(strings.Join(tt.args, " "), func(t *testing.T) {
cmd, _, err := rootCmd.Find(tt.args)
if err != nil {
t.Fatalf("find %v: %v", tt.args, err)
}
calls = 0
runGatedSyncMutationHook(cmd)
if calls != tt.want {
t.Errorf("post-mutation sync ran %d times, want %d", calls, tt.want)
}
})
}
}

func TestAutoSyncEnabled_Default(t *testing.T) {
// With no env var set, auto-sync should be enabled by default
t.Setenv("TD_SYNC_AUTO", "")
Expand Down
17 changes: 15 additions & 2 deletions cmd/feature_gate.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"strings"

"github.com/marcus/td/internal/features"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -60,8 +62,7 @@ func runGatedSyncMutationHook(cmd *cobra.Command) {

// Skip everything for non-mutating (read-only) commands: the stranded warning
// and the autosync hook both only concern commands that change local data.
commandName := resolveCommandName(cmd)
if syncFeatureHooks.IsMutatingCommand != nil && !syncFeatureHooks.IsMutatingCommand(commandName) {
if !isMutatingInvocation(cmd) {
return
}

Expand All @@ -80,6 +81,18 @@ func runGatedSyncMutationHook(cmd *cobra.Command) {
syncFeatureHooks.OnAfterMutation()
}

// isMutatingInvocation reports whether cmd counts as mutating, checking both its
// resolved name (its parent's, for a subcommand) and its full path below the
// root, such as "comments add". Without a registered check, every command counts.
func isMutatingInvocation(cmd *cobra.Command) bool {
isMutating := syncFeatureHooks.IsMutatingCommand
if isMutating == nil {
return true
}
path := strings.TrimPrefix(cmd.CommandPath(), cmd.Root().CommandPath()+" ")
return isMutating(resolveCommandName(cmd)) || isMutating(path)
}

func resolveCommandName(cmd *cobra.Command) string {
name := cmd.Name()
if cmd.Parent() != nil && cmd.Parent().Name() != "td" {
Expand Down
Loading