From 05192b5f0cb28102b9448412fe178b335da9a17c Mon Sep 17 00:00:00 2001 From: Peter Donald Date: Wed, 15 Jul 2026 08:23:18 +1000 Subject: [PATCH] feat(diff): add sync-push source filter Add --sync-push-only to limit diffs to sources opted into sync pushes while preserving existing diff semantics and passthrough behavior. --- README.md | 8 +++++- integration/completion_test.go | 5 ++++ integration/lifecycle_test.go | 35 ++++++++++++++++++++++++ internal/cli/cli.go | 8 +++--- internal/cli/cli_test.go | 20 ++++++++++++-- internal/cli/schema.go | 2 +- internal/command/completion_test.go | 5 ++++ internal/command/diff.go | 41 ++++++++++++++++------------ internal/command/diff_test.go | 42 +++++++++++++++++++++++++++++ 9 files changed, 142 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 300884c..c2dbc69 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,7 @@ Show local changes as a Git diff: ```bash braid diff +braid diff --sync-push-only braid diff vendor/rails braid diff :rails braid diff vendor/rails -- --stat @@ -309,6 +310,11 @@ braid diff vendor/rails -- --cached A mirror-path selector diffs only that local mirror. A `:source` selector diffs every mirror belonging to the source. +`--sync-push-only` limits the command to sources configured with +`"sync_push": true`. Ineligible sources are skipped quietly, including when +explicitly selected. The option filters sources only; each selected mirror +retains the normal `braid diff` comparison behavior. + Arguments after `--` are passed to `git diff`. This is useful for generating patches, limiting output, or checking staged changes only. @@ -663,7 +669,7 @@ includes it. | --- | --- | | `add [--no-commit]` | Add a source with mirrors, or add mirrors to an existing `:source`. | | `status` | Show whether mirrors have remote, local, or removal changes. | -| `diff` | Show local mirror changes, with Git diff arguments after `--`. | +| `diff [--sync-push-only]` | Show local mirror changes, optionally limited to sources that participate in sync pushes, with Git diff arguments after `--`. | | `pull [--no-commit]` | Pull one source atomically, or every eligible source. | | `push` | Push a source's committed mirror changes upstream as one commit. | | `sync [selector...] [--pull-only] [--autostash] [--keep]` | Push then pull selected sources. | diff --git a/integration/completion_test.go b/integration/completion_test.go index 98ab957..6b7d97b 100644 --- a/integration/completion_test.go +++ b/integration/completion_test.go @@ -46,6 +46,11 @@ func TestExecutableBashCompletion(t *testing.T) { noRepoMirrors := completeExecutable(t, env, root, braid, "status", "") assertCompletionCandidate(t, noRepoMirrors, "help") + + diffOptions := completeExecutable(t, env, root, braid, "diff", "") + assertCompletionCandidate(t, diffOptions, "--sync-push-only") + diffOptionsAfterSelector := completeExecutable(t, env, root, braid, "diff", "mirror", "") + assertCompletionCandidate(t, diffOptionsAfterSelector, "--sync-push-only") } func TestExecutableBashCompletionCoversEveryCommandAndOptionPosition(t *testing.T) { diff --git a/integration/lifecycle_test.go b/integration/lifecycle_test.go index 4d53226..27ba2ec 100644 --- a/integration/lifecycle_test.go +++ b/integration/lifecycle_test.go @@ -113,6 +113,41 @@ func TestExecutablePrimaryLifecycle(t *testing.T) { assertClean(t, env, downstream) } +func TestExecutableDiffSyncPushOnly(t *testing.T) { + root := t.TempDir() + env := newProcessEnv(t, root) + braid := braidBinary(t) + + upstreamEnabled := filepath.Join(root, "upstream-enabled") + initRepo(t, env, upstreamEnabled) + writeFile(t, upstreamEnabled, "README.md", "enabled base\n") + commitAll(t, env, upstreamEnabled, "enabled") + upstreamDisabled := filepath.Join(root, "upstream-disabled") + initRepo(t, env, upstreamDisabled) + writeFile(t, upstreamDisabled, "README.md", "disabled base\n") + commitAll(t, env, upstreamDisabled, "disabled") + + downstream := filepath.Join(root, "downstream") + initRepo(t, env, downstream) + writeFile(t, downstream, "README.md", "downstream\n") + commitAll(t, env, downstream, "downstream") + assertResult(t, runBraid(t, env, downstream, braid, "--quiet", "add", upstreamEnabled, "vendor/enabled", "--sync-push"), 0, "", "") + assertResult(t, runBraid(t, env, downstream, braid, "--quiet", "add", upstreamDisabled, "vendor/disabled"), 0, "", "") + writeFile(t, downstream, "vendor/enabled/README.md", "enabled changed\n") + writeFile(t, downstream, "vendor/disabled/README.md", "disabled changed\n") + + filtered := runBraid(t, env, downstream, braid, "diff", "--sync-push-only") + assertExit(t, filtered, 0) + assertEmpty(t, "filtered diff stderr", filtered.stderr) + assertContains(t, filtered.stdout, "Braid: Diffing mirror vendor/enabled") + assertContains(t, filtered.stdout, "enabled changed") + assertNotContains(t, filtered.stdout, "vendor/disabled") + assertNotContains(t, filtered.stdout, "disabled changed") + + skipped := runBraid(t, env, downstream, braid, "diff", "vendor/disabled", "--sync-push-only") + assertResult(t, skipped, 0, "", "") +} + func TestUpgradeConfigCommitAndNoCommit(t *testing.T) { for _, noCommit := range []bool{false, true} { t.Run(map[bool]string{false: "commit", true: "no-commit"}[noCommit], func(t *testing.T) { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a7bcd09..0ac8bf6 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -68,9 +68,10 @@ type RemoveOptions struct { } type DiffOptions struct { - LocalPath string - Keep bool - GitDiffArgs []string + LocalPath string + Keep bool + SyncPushOnly bool + GitDiffArgs []string } type PushOptions struct { @@ -423,6 +424,7 @@ func parseDiff(args []string, options *DiffOptions) error { options.LocalPath = normalizeLocalPathArg(parsed.positionals[0]) } options.Keep = parsed.has("--keep") + options.SyncPushOnly = parsed.has("--sync-push-only") options.GitDiffArgs = parsed.passthrough return nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 60e4e82..27d47a7 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -72,13 +72,18 @@ func TestParseCommands(t *testing.T) { }, { name: "diff passthrough", - args: []string{"--no-cache", "--quiet", "diff", "vendor/repo", "--", "--stat", "weird;path"}, + args: []string{"--no-cache", "--quiet", "diff", "vendor/repo", "--sync-push-only", "--", "--stat", "weird;path"}, want: Invocation{ Global: GlobalOptions{NoCache: true, Quiet: true}, Command: CommandDiff, - Diff: DiffOptions{LocalPath: "vendor/repo", GitDiffArgs: []string{"--stat", "weird;path"}}, + Diff: DiffOptions{LocalPath: "vendor/repo", SyncPushOnly: true, GitDiffArgs: []string{"--stat", "weird;path"}}, }, }, + { + name: "diff sync push only before selector", + args: []string{"diff", "--sync-push-only", "vendor/repo"}, + want: Invocation{Command: CommandDiff, Diff: DiffOptions{LocalPath: "vendor/repo", SyncPushOnly: true}}, + }, { name: "diff verbose passthrough", args: []string{"diff", "vendor/repo", "--", "--verbose"}, @@ -87,6 +92,14 @@ func TestParseCommands(t *testing.T) { Diff: DiffOptions{LocalPath: "vendor/repo", GitDiffArgs: []string{"--verbose"}}, }, }, + { + name: "diff sync push only text after separator is passthrough", + args: []string{"diff", "vendor/repo", "--", "--sync-push-only"}, + want: Invocation{ + Command: CommandDiff, + Diff: DiffOptions{LocalPath: "vendor/repo", GitDiffArgs: []string{"--sync-push-only"}}, + }, + }, { name: "push", args: []string{"-v", "push", "vendor/repo", "-b", "main", "--keep"}, @@ -336,6 +349,9 @@ func TestUsageDocumentsVerboseAsGlobalOnly(t *testing.T) { if got, want := CommandUsage(CommandRemove), "usage: braid remove [--keep] [--no-commit]\n"; got != want { t.Fatalf("CommandUsage(remove) = %q, want %q", got, want) } + if got, want := CommandUsage(CommandDiff), "usage: braid diff [local_path|:source] [--keep] [--sync-push-only] [-- ...]\n"; got != want { + t.Fatalf("CommandUsage(diff) = %q, want %q", got, want) + } if got, want := CommandUsage(CommandPush), "usage: braid push [--branch|-b ] [--message|-m ] [--keep]\n"; got != want { t.Fatalf("CommandUsage(push) = %q, want %q", got, want) } diff --git a/internal/cli/schema.go b/internal/cli/schema.go index 15822cf..a29adcf 100644 --- a/internal/cli/schema.go +++ b/internal/cli/schema.go @@ -181,7 +181,7 @@ var commandSpecs = []CommandSpec{ }, { Command: CommandDiff, Name: "diff", Summary: "Show local mirror changes", - Options: []OptionSpec{{Long: "--keep"}}, + Options: []OptionSpec{{Long: "--keep"}, {Long: "--sync-push-only"}}, Positionals: []PositionalSpec{{Name: "local_path|:source", Usage: "[local_path|:source]", Completion: CompletionMirror}}, Passthrough: &PassthroughSpec{Usage: "[-- ...]"}, }, diff --git a/internal/command/completion_test.go b/internal/command/completion_test.go index c94440b..4ba6724 100644 --- a/internal/command/completion_test.go +++ b/internal/command/completion_test.go @@ -98,6 +98,11 @@ func TestCompleteCommandOptions(t *testing.T) { candidates = completeCandidates(t, dir, "remove", "") assertCandidate(t, candidates, "--no-commit") + candidates = completeCandidates(t, dir, "diff", "") + assertCandidate(t, candidates, "--sync-push-only") + candidates = completeCandidates(t, dir, "diff", "mirror", "") + assertCandidate(t, candidates, "--sync-push-only") + candidates = completeCandidates(t, dir, "pull", "--no-commit", "--") assertNoCandidate(t, candidates, "--no-commit") diff --git a/internal/command/diff.go b/internal/command/diff.go index eadb951..99f65e9 100644 --- a/internal/command/diff.go +++ b/internal/command/diff.go @@ -33,35 +33,42 @@ func (h DiffHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if err := validateConfigPaths(cfg); err != nil { return err } - cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, stderr) - if err != nil { - return err - } + var mirrors []source.SourceMirror + showHeaders := inv.Diff.LocalPath == "" if inv.Diff.LocalPath != "" { selection, err := resolveSourceSelection(repo, cfg, inv.Diff.LocalPath, true) if err != nil { return err } + if inv.Diff.SyncPushOnly && !selection.Source.SyncPush { + return nil + } for _, mirror := range selection.Mirrors { - if len(selection.Mirrors) > 1 { - if _, err := fmt.Fprintf(stdout, "=======================================================\nBraid: Diffing mirror %s\n=======================================================\n", mirror.LocalPath); err != nil { - return err - } - } - if err := h.diffOne(ctx, git, processGit, cache, selection.Source.WithMirror(mirror), inv.Diff, inv.Global.Verbose, progress, stdout, stderr); err != nil { - return err + mirrors = append(mirrors, selection.Source.WithMirror(mirror)) + } + showHeaders = len(mirrors) > 1 + } else { + for _, mirror := range cfg.MirrorsSorted() { + if !inv.Diff.SyncPushOnly || mirror.SyncPush { + mirrors = append(mirrors, mirror) } } + } + if len(mirrors) == 0 { return nil } - - for _, m := range cfg.MirrorsSorted() { - localPath := m.LocalPath - if _, err := fmt.Fprintf(stdout, "=======================================================\nBraid: Diffing mirror %s\n=======================================================\n", localPath); err != nil { - return err + cache, err := runtimeCacheForRepo(ctx, repo, inv.Global, inv.Global.Verbose, stderr) + if err != nil { + return err + } + for _, mirror := range mirrors { + if showHeaders { + if _, err := fmt.Fprintf(stdout, "=======================================================\nBraid: Diffing mirror %s\n=======================================================\n", mirror.LocalPath); err != nil { + return err + } } - if err := h.diffOne(ctx, git, processGit, cache, m, inv.Diff, inv.Global.Verbose, progress, stdout, stderr); err != nil { + if err := h.diffOne(ctx, git, processGit, cache, mirror, inv.Diff, inv.Global.Verbose, progress, stdout, stderr); err != nil { return err } } diff --git a/internal/command/diff_test.go b/internal/command/diff_test.go index 1d0341b..30a2481 100644 --- a/internal/command/diff_test.go +++ b/internal/command/diff_test.go @@ -84,6 +84,48 @@ func TestDiffCommandAllMirrors(t *testing.T) { assertContains(t, out, "two changed") } +func TestDiffCommandSyncPushOnlyFiltersSources(t *testing.T) { + upstreamEnabled := testutil.InitRepo(t) + testutil.WriteFile(t, upstreamEnabled, "README.md", "enabled base\n") + testutil.CommitAll(t, upstreamEnabled, "enabled") + + upstreamDisabled := testutil.InitRepo(t) + testutil.WriteFile(t, upstreamDisabled, "README.md", "disabled base\n") + testutil.CommitAll(t, upstreamDisabled, "disabled") + + repo := initDownstream(t) + runCommandOK(t, repo, []string{"add", upstreamEnabled, "--name", "enabled", "vendor/enabled-a", "vendor/enabled-b", "--sync-push"}) + runCommandOK(t, repo, []string{"add", upstreamDisabled, "--name", "disabled", "vendor/disabled"}) + testutil.WriteFile(t, repo, "vendor/enabled-a/README.md", "enabled a changed\n") + testutil.WriteFile(t, repo, "vendor/enabled-b/README.md", "enabled b changed\n") + testutil.WriteFile(t, repo, "vendor/disabled/README.md", "disabled changed\n") + + out := runCommandOK(t, repo, []string{"diff", "--sync-push-only"}) + assertContains(t, out, "Braid: Diffing mirror vendor/enabled-a") + assertContains(t, out, "Braid: Diffing mirror vendor/enabled-b") + assertContains(t, out, "enabled a changed") + assertContains(t, out, "enabled b changed") + assertNotContains(t, out, "vendor/disabled") + assertNotContains(t, out, "disabled changed") + + sourceOut := runCommandOK(t, repo, []string{"diff", ":enabled", "--sync-push-only"}) + assertContains(t, sourceOut, "Braid: Diffing mirror vendor/enabled-a") + assertContains(t, sourceOut, "Braid: Diffing mirror vendor/enabled-b") + + disabledOut, disabledErr := runCommandOKWithOutput(t, repo, []string{"diff", "vendor/disabled", "--sync-push-only"}) + if disabledOut != "" || disabledErr != "" { + t.Fatalf("disabled source output = (%q, %q), want empty", disabledOut, disabledErr) + } + + disabledOnlyRepo := initDownstream(t) + runCommandOK(t, disabledOnlyRepo, []string{"add", upstreamDisabled, "vendor/disabled"}) + testutil.WriteFile(t, disabledOnlyRepo, "vendor/disabled/README.md", "disabled changed\n") + disabledOut, disabledErr = runCommandOKWithOutput(t, disabledOnlyRepo, []string{"diff", "--sync-push-only"}) + if disabledOut != "" || disabledErr != "" { + t.Fatalf("no eligible sources output = (%q, %q), want empty", disabledOut, disabledErr) + } +} + func TestDiffCommandMirrorVariants(t *testing.T) { tests := []struct { name string