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
46 changes: 31 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ Start anywhere inside an existing Git repository. Add a named upstream source
and one local mirror:

```bash
braid add <upstream-git-url> lib/grit
braid add <upstream-git-url> lib/grit --sync-push
```

Braid copies the upstream content into `lib/grit`, records the source and mirror
Expand All @@ -174,7 +174,7 @@ Use `--no-commit` when the mirror add belongs in the same commit as other
changes:

```bash
braid add <upstream-git-url> lib/grit --no-commit
braid add <upstream-git-url> lib/grit --sync-push --no-commit
git commit
```

Expand All @@ -200,17 +200,18 @@ braid push lib/grit
braid push lib/grit --branch myproject_customizations
```

For the common branch-mirror workflow, `sync` combines the tracked-branch push
and follow-up pull:
Because the source was added with `--sync-push`, `sync` combines the
tracked-branch push and follow-up pull:

```bash
braid sync lib/grit
```

It pushes committed local mirror changes when the branch is still up to date,
then pulls the selected mirror so `.braids.json` records the new upstream
revision. Use the explicit `push` and `pull` commands when you need to push to
a different branch or handle each step separately.
For sources with `"sync_push": true`, it pushes committed local mirror changes
when the branch is still up to date. It then pulls every selected source,
including sources that are not opted into sync pushes, so `.braids.json`
records the new upstream revision. Use the explicit `push` and `pull` commands
when you need to push to a different branch or handle each step separately.

```bash
braid pull lib/grit
Expand Down Expand Up @@ -240,6 +241,17 @@ braid add https://github.com/rails/rails.git vendor/rails --branch 5-0-stable
braid add https://github.com/rails/rails.git vendor/rails-7 --tag v7.0.0
```

Opt a branch-tracking source into the push phase of `braid sync`:

```bash
braid add https://github.com/rails/rails.git vendor/rails --sync-push
```

Without `--sync-push`, `braid sync` pulls the source but does not push it.
`--sync-push` cannot be combined with `--tag` or `--revision`, and cannot be
used when adding mirrors to an existing `:source`. Explicit `braid push` is
unaffected by this setting.

Lock a source to an explicit revision when you do not want ordinary
`braid pull` runs to move it:

Expand Down Expand Up @@ -368,7 +380,8 @@ mirror path and `.braids.json` from `HEAD`, then remove `.git/MERGE_MSG`.

### Syncing Mirrors

`braid sync` runs the safe push-then-pull workflow for branch-tracking sources:
`braid sync` pulls selected sources and first pushes committed changes for
branch-tracking sources that have opted in with `sync_push`:

```bash
braid sync vendor/rails
Expand Down Expand Up @@ -426,12 +439,12 @@ index state. If automatic restoration succeeds but the saved stash cannot be
dropped safely, Braid leaves your restored work in place, keeps the stash
recoverable, and tells you to inspect `git stash list` before manual cleanup.

The default push phase only auto-pushes branch-tracking sources with committed
local mirror changes. Branch sources without committed local changes are skipped
quietly and still update normally, even if upstream has moved. Selected tag or
revision-locked sources with committed local changes stop the sync because `sync` has
no `--branch`; run `braid push <path> --branch <branch>` for that explicit push
intent, or rerun with `--pull-only` if you only intended to pull.
The push phase only considers branch-tracking sources with `"sync_push": true`.
Sources with omitted or false `sync_push` are skipped quietly during that phase
and still update normally, even when explicitly selected. Opted-in branch
sources without committed local changes are also skipped quietly. Tag and
revision-locked sources cannot enable `sync_push`; use
`braid push <path> --branch <branch>` for explicit push intent.

If a changed branch source's upstream branch moved since the recorded revision,
`sync` fails before any mirror is pushed. Pull first, resolve conflicts if
Expand Down Expand Up @@ -623,6 +636,7 @@ Config version 2 records named sources and their mirrors:
"url": "https://github.com/replicant4j/replicant.git",
"branch": "master",
"revision": "18480c9dc34f948218a0c15370712d27b2626fa0",
"sync_push": true,
"mirrors": {
"licenses/replicant-LICENSE.txt": "LICENSE.txt",
"vendor/libs/replicant": ""
Expand All @@ -635,6 +649,8 @@ Config version 2 records named sources and their mirrors:
Braid validates configured mirror paths for cross-platform safety. It does not
preflight every file inside the selected upstream tree; if an upstream filename
cannot be materialized on the current OS, Git reports the checkout failure.
The optional `sync_push` field defaults to false and is only valid for
branch-tracking sources.

When you vendor only a subdirectory or single file, remember that license files
outside the mirrored path are not copied automatically. If the upstream license
Expand Down
23 changes: 14 additions & 9 deletions docs/migration-from-ruby-braid.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,12 @@ Ruby Braid has no `sync` command. Current Go Braid adds:
braid sync [selector...] [--pull-only] [--autostash] [--keep]
```

The default `sync` workflow pushes committed local changes for branch-tracking
sources and then pulls every mirror in each source to the new upstream revision.
`--pull-only` skips the push phase and only pulls. With no selectors, `sync`
The default `sync` workflow pushes committed local changes only for
branch-tracking sources configured with `"sync_push": true`, then pulls every
mirror in each selected source to the new upstream revision. Add a new opted-in
source with `braid add <url> <path> --sync-push`. Sources with omitted or false
`sync_push` are pull-only during sync, even when explicitly selected.
`--pull-only` skips the push phase for every source. With no selectors, `sync`
selects all branch and tag sources in lexicographic source-name order and skips
revision-locked sources, matching no-selector `pull` selection. A selector may
be `:source` or one of its mirror paths; aliases are deduplicated.
Expand All @@ -117,12 +120,14 @@ changes; the push phase still uses the mirror content recorded in downstream

Migration impact:

- Use `sync` for the common "push local mirror commits upstream, then pull the
downstream recorded revision" workflow.
- Set `"sync_push": true` on branch sources that should participate in sync's
push phase; omission is pull-only and requires no config version upgrade.
- Use `sync` for the common "push opted-in local mirror commits upstream, then
pull the downstream recorded revision" workflow.
- Use `sync --pull-only` as a stricter, scoped pull workflow when you do not
want any push attempt.
- Do not expect `sync` to push tag-tracking or revision-locked sources with local changes unless
you use `braid push <path> --branch <branch>` explicitly.
- Tag-tracking and revision-locked sources cannot enable `sync_push`; use
`braid push <path> --branch <branch>` explicitly.

### `push` is more explicit about committed changes and commit messages

Expand Down Expand Up @@ -334,6 +339,6 @@ Known output differences include:
shared full-cache path.
8. For push workflows, make sure local mirror edits are committed downstream
before `braid push` or `braid sync`.
9. Prefer `braid sync` for the push-then-pull workflow once the team has
tested it on the repository.
9. Enable `sync_push` only for branch sources that should be pushed, then use
`braid sync` for the push-then-pull workflow.
10. Optionally install Bash completion with `braid completion bash`.
6 changes: 4 additions & 2 deletions integration/support.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ type configSource struct {
Tag string `json:"tag,omitempty"`
Revision string `json:"revision"`
PartialClone bool `json:"partial_clone,omitempty"`
SyncPush bool `json:"sync_push,omitempty"`
Mirrors map[string]string `json:"mirrors"`
}

Expand All @@ -298,6 +299,7 @@ type configMirror struct {
Tag string `json:"tag,omitempty"`
Revision string `json:"revision"`
PartialClone bool `json:"partial_clone,omitempty"`
SyncPush bool `json:"sync_push,omitempty"`
}

func expectedConfigRaw(t *testing.T, mirrors map[string]configMirror) string {
Expand All @@ -312,7 +314,7 @@ func expectedConfigRaw(t *testing.T, mirrors map[string]configMirror) string {
groups := map[string]string{}
for _, local := range keys {
m := mirrors[local]
key := strings.Join([]string{strings.TrimRight(m.URL, `/\`), m.Branch, m.Tag, m.Revision, strconv.FormatBool(m.PartialClone)}, "\x00")
key := strings.Join([]string{strings.TrimRight(m.URL, `/\`), m.Branch, m.Tag, m.Revision, strconv.FormatBool(m.PartialClone), strconv.FormatBool(m.SyncPush)}, "\x00")
name := groups[key]
if name == "" {
name = strings.TrimSuffix(path.Base(strings.ReplaceAll(strings.TrimRight(m.URL, `/\`), `\`, "/")), ".git")
Expand All @@ -321,7 +323,7 @@ func expectedConfigRaw(t *testing.T, mirrors map[string]configMirror) string {
name = fmt.Sprintf("%s-%d", name, used[name])
}
groups[key] = name
sources[name] = configSource{URL: strings.TrimRight(m.URL, `/\`), Branch: m.Branch, Tag: m.Tag, Revision: m.Revision, PartialClone: m.PartialClone, Mirrors: map[string]string{}}
sources[name] = configSource{URL: strings.TrimRight(m.URL, `/\`), Branch: m.Branch, Tag: m.Tag, Revision: m.Revision, PartialClone: m.PartialClone, SyncPush: m.SyncPush, Mirrors: map[string]string{}}
}
s := sources[name]
s.Mirrors[local] = m.Path
Expand Down
6 changes: 3 additions & 3 deletions integration/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ func TestExecutableSyncPushesThenUpdates(t *testing.T) {
initRepo(t, env, downstream)
writeFile(t, downstream, "README.md", "downstream\n")
commitAll(t, env, downstream, "seed downstream")
add := runBraid(t, env, downstream, braid, "--quiet", "add", upstream, "vendor/basic")
add := runBraid(t, env, downstream, braid, "--quiet", "add", upstream, "vendor/basic", "--sync-push")
assertResult(t, add, 0, "", "")
assertConfigRaw(t, downstream, map[string]configMirror{
"vendor/basic": {URL: upstream, Branch: "main", Revision: baseRevision},
"vendor/basic": {URL: upstream, Branch: "main", Revision: baseRevision, SyncPush: true},
})

writeFile(t, downstream, "vendor/basic/README.md", "local\n")
Expand All @@ -46,7 +46,7 @@ func TestExecutableSyncPushesThenUpdates(t *testing.T) {
pushedRevision := gitOutput(t, env, upstream, "rev-parse", "HEAD")
assertLatestCommit(t, env, upstream, defaultName+" <"+defaultEmail+">", "Executable sync")
assertConfigRaw(t, downstream, map[string]configMirror{
"vendor/basic": {URL: upstream, Branch: "main", Revision: pushedRevision},
"vendor/basic": {URL: upstream, Branch: "main", Revision: pushedRevision, SyncPush: true},
})
assertLatestCommit(t, env, downstream, defaultName+" <"+defaultEmail+">", "Braid: Update source 'upstream' to '"+shortRevision(pushedRevision)+"'")
assertNoRemote(t, env, downstream, remoteName("main", "upstream"))
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type AddOptions struct {
Revision string
NoCommit bool
PartialClone bool
SyncPush bool
}

type MirrorMapping struct {
Expand Down Expand Up @@ -354,6 +355,7 @@ func parseAdd(args []string, options *AddOptions) error {
options.Revision = parsed.value("--revision")
options.NoCommit = parsed.has("--no-commit")
options.PartialClone = parsed.has("--partial-clone")
options.SyncPush = parsed.has("--sync-push")
if strings.HasPrefix(positionals[0], ":") {
options.ExistingSource = strings.TrimPrefix(positionals[0], ":")
if options.ExistingSource == "" {
Expand Down
10 changes: 7 additions & 3 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestParseCommands(t *testing.T) {
}{
{
name: "add branch mirror",
args: []string{"--verbose", "--global-cache-dir", ".cache", "add", "https://example.test/repo.git", "vendor/repo=lib", "--branch", "main", "--no-commit"},
args: []string{"--verbose", "--global-cache-dir", ".cache", "add", "https://example.test/repo.git", "vendor/repo=lib", "--branch", "main", "--no-commit", "--sync-push"},
want: Invocation{
Global: GlobalOptions{GlobalCacheDir: ".cache", GlobalCacheDirSet: true, Verbose: true},
Command: CommandAdd,
Expand All @@ -26,6 +26,7 @@ func TestParseCommands(t *testing.T) {
Mirrors: []MirrorMapping{{LocalPath: "vendor/repo", UpstreamPath: "lib"}},
Branch: "main",
NoCommit: true,
SyncPush: true,
},
},
},
Expand Down Expand Up @@ -188,6 +189,9 @@ func TestParseUsageErrors(t *testing.T) {
{name: "empty global cache dir", args: []string{"--global-cache-dir=", "version"}, want: "--global-cache-dir requires a non-empty value"},
{name: "existing source needs mirror", args: []string{"add", ":source"}, want: "add to an existing source requires at least one mirror"},
{name: "tag branch conflict", args: []string{"add", "url", "--tag", "v1", "--branch", "main"}, want: "add cannot combine --tag and --branch"},
{name: "sync push tag conflict", args: []string{"add", "url", "--sync-push", "--tag", "v1"}, want: "add cannot combine --sync-push and --tag"},
{name: "sync push revision conflict", args: []string{"add", "url", "--sync-push", "--revision", "abc"}, want: "add cannot combine --sync-push and --revision"},
{name: "existing source sync push", args: []string{"add", ":source", "vendor/new", "--sync-push"}, want: "add to an existing source cannot use --name, --branch, --tag, --revision, --partial-clone, or --sync-push"},
{name: "pull all strategy flag", args: []string{"pull", "--branch", "main"}, want: "pull without local_path cannot use --branch, --tag, or --revision"},
{name: "diff args require separator", args: []string{"diff", "--stat"}, want: "unknown flag for diff: --stat"},
{name: "sync unknown flag", args: []string{"sync", "--branch", "main"}, want: "unknown flag for sync: --branch"},
Expand Down Expand Up @@ -314,7 +318,7 @@ func TestUsageDocumentsVerboseAsGlobalOnly(t *testing.T) {
if strings.Contains(Usage(), " update") || strings.Contains(Usage(), "\n up ") {
t.Fatalf("top-level usage exposes update aliases:\n%s", Usage())
}
if !strings.Contains(Usage(), " sync Push local mirror changes, then pull sources") {
if !strings.Contains(Usage(), " sync Push opted-in local changes, then pull sources") {
t.Fatalf("top-level usage missing sync command:\n%s", Usage())
}
if !strings.Contains(Usage(), " completion") {
Expand All @@ -323,7 +327,7 @@ func TestUsageDocumentsVerboseAsGlobalOnly(t *testing.T) {
if strings.Contains(Usage(), "__complete") {
t.Fatalf("top-level usage exposes hidden completion callback:\n%s", Usage())
}
if got, want := CommandUsage(CommandAdd), "usage: braid add <url|:source> [local_path[=upstream_path]...] [--name <name>] [--branch|-b <branch>] [--tag|-t <tag>] [--revision|-r <rev>] [--no-commit] [--partial-clone]\n"; got != want {
if got, want := CommandUsage(CommandAdd), "usage: braid add <url|:source> [local_path[=upstream_path]...] [--name <name>] [--branch|-b <branch>] [--tag|-t <tag>] [--revision|-r <rev>] [--no-commit] [--partial-clone] [--sync-push]\n"; got != want {
t.Fatalf("CommandUsage(add) = %q, want %q", got, want)
}
if got, want := CommandUsage(CommandPull), "usage: braid pull [local_path|:source] [--branch|-b <branch>] [--tag|-t <tag>] [--revision|-r <rev>] [--keep] [--no-commit]\n"; got != want {
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ var commandSpecs = []CommandSpec{
{Long: "--revision", Short: "-r", ValueName: "revision", UsageValue: "rev", Availability: Availability{ForbiddenPrefix: ":"}},
{Long: "--no-commit"},
{Long: "--partial-clone", Availability: Availability{ForbiddenPrefix: ":"}},
{Long: "--sync-push", Availability: Availability{ForbiddenPrefix: ":"}},
},
Positionals: []PositionalSpec{
{Name: "url|:source", Usage: "<url|:source>", Required: true, Completion: CompletionFree},
Expand All @@ -151,9 +152,11 @@ var commandSpecs = []CommandSpec{
Conflicts: []ConflictSpec{
{Options: []string{"--tag", "--branch"}, Error: "add cannot combine --tag and --branch"},
{Options: []string{"--tag", "--revision"}, Error: "add cannot combine --tag and --revision"},
{Options: []string{"--sync-push", "--tag"}, Error: "add cannot combine --sync-push and --tag"},
{Options: []string{"--sync-push", "--revision"}, Error: "add cannot combine --sync-push and --revision"},
},
ConditionalPositionals: []ConditionalPositionalsSpec{{Index: 0, Prefix: ":", Minimum: 2, Error: "add to an existing source requires at least one mirror"}},
AvailabilityError: "add to an existing source cannot use --name, --branch, --tag, --revision, or --partial-clone",
AvailabilityError: "add to an existing source cannot use --name, --branch, --tag, --revision, --partial-clone, or --sync-push",
},
{
Command: CommandPull, Name: "pull", Aliases: []string{"update", "up"}, Summary: "Pull one source or every eligible source",
Expand Down Expand Up @@ -188,7 +191,7 @@ var commandSpecs = []CommandSpec{
Positionals: []PositionalSpec{{Name: "local_path|:source", Usage: "<local_path|:source>", Required: true, Completion: CompletionMirror}},
},
{
Command: CommandSync, Name: "sync", Summary: "Push local mirror changes, then pull sources",
Command: CommandSync, Name: "sync", Summary: "Push opted-in local changes, then pull sources",
Options: []OptionSpec{{Long: "--pull-only"}, {Long: "--autostash"}, {Long: "--keep"}},
Positionals: []PositionalSpec{{Name: "local_path|:source", Usage: "[local_path|:source ...]", Repeatable: true, Completion: CompletionMirror}},
},
Expand Down
22 changes: 15 additions & 7 deletions internal/clitest/completion_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,22 @@ func commandCases(spec cli.CommandSpec, commandName string) []CompletionCase {
if len(conflict.Options) < 2 {
continue
}
first, second := conflict.Options[0], conflict.Options[1]
words := []string{commandName, first}
if option, ok := spec.Option(first); ok && option.TakesValue() {
words = append(words, "value")
for _, selected := range conflict.Options {
words := []string{commandName, selected}
if option, ok := spec.Option(selected); ok && option.TakesValue() {
words = append(words, "value")
}
words = append(words, "--")
var unwanted []string
for _, conflicting := range conflict.Options {
if conflicting == selected {
continue
}
option, _ := spec.Option(conflicting)
unwanted = append(unwanted, optionNames(option)...)
}
cases = append(cases, CompletionCase{Name: commandName + " conflict " + selected + " in " + strings.Join(conflict.Options, " "), Words: words, Unwanted: unwanted})
}
words = append(words, "--")
option, _ := spec.Option(second)
cases = append(cases, CompletionCase{Name: commandName + " conflict " + strings.Join(conflict.Options, " "), Words: words, Unwanted: optionNames(option)})
}
return cases
}
Expand Down
2 changes: 1 addition & 1 deletion internal/command/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func (h AddHandler) add(ctx context.Context, repo RepoContext, git AddGit, inv c
} else if addOptions.Tag != "" {
tracking = source.TagTracking{Tag: addOptions.Tag}
}
s = source.Source{Name: name, URL: source.CleanURL(addOptions.URL), Tracking: tracking, Revision: addOptions.Revision, PartialClone: addOptions.PartialClone}
s = source.Source{Name: name, URL: source.CleanURL(addOptions.URL), Tracking: tracking, Revision: addOptions.Revision, PartialClone: addOptions.PartialClone, SyncPush: addOptions.SyncPush}
}
requested := addOptions.Mirrors
if len(requested) == 0 {
Expand Down
13 changes: 13 additions & 0 deletions internal/command/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ func TestAddCommandDefaultBranchCommitsAndRemovesRemote(t *testing.T) {
assertClean(t, repo)
}

func TestAddCommandEnablesSyncPush(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, "README.md", "upstream\n")
testutil.CommitAll(t, upstream, "upstream")
repo := initDownstream(t)

runCommandOK(t, repo, []string{"add", upstream, "vendor/basic", "--sync-push"})

if m := loadMirror(t, repo, "vendor/basic"); !m.SyncPush {
t.Fatalf("mirror = %#v, want sync push enabled", m)
}
}

func TestAddCommandPreservesUnrelatedIndexAndWorktreeState(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, "README.md", "hello from upstream\n")
Expand Down
Loading