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
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,12 @@ bazel run //cmd/braid:braid -- version
## Usage

Braid commands run from any directory inside a Git working tree. Commands that
create automatic commits (`add`, `pull`, and `remove`) require their
command-owned paths to be clean, block unresolved Git operations, and leave
unrelated staged, unstaged, and untracked work untouched and out of Braid's
commits. `status`, `diff`, and `push` are the usual commands for deciding
whether to pull, prepare a patch, or send local mirror changes upstream.
create automatic commits (`add`, `pull`, and `remove`) block unresolved Git
operations and leave unrelated staged, unstaged, and untracked work untouched
and out of Braid's commits. Their command-owned paths must be clean, except that
ignored files do not block `pull` or `remove`. `status`, `diff`, and `push` are
the usual commands for deciding whether to pull, prepare a patch, or send local
mirror changes upstream.

Use `--no-commit` with `add`, `pull`, `remove`, or `upgrade-config` to stage Braid's changes
without creating the automatic commit. Braid stages only `.braids.json` and the
Expand Down Expand Up @@ -365,9 +366,12 @@ braid pull vendor/rails --tag <tag>
```

Before pulling, Braid requires `.braids.json` and every mirror path in the source
to be clean in both the index and working tree. For `braid pull` without a
selector, that scoped cleanliness check covers every mirror of every eligible
source before any source is fetched or updated.
to have no staged, tracked, or ordinary untracked changes. Ignored files under a
mirror do not block the pull and remain in place. If incoming tracked content
would overwrite an ignored local path, Braid stops before changing the
repository. For `braid pull` without a selector, that scoped cleanliness check
covers every mirror of every eligible source before any source is fetched or
updated.

Use `--no-commit` to stage a pull without creating the automatic Braid commit:

Expand Down Expand Up @@ -426,7 +430,8 @@ Before any fetch, push, editor, worktree write, config write, or pull commit,
`sync` checks unresolved Git operation state, `.braids.json`, and every selected
mirror path for index and working tree changes. Dirty mirrors outside an
explicit selection do not block that explicit sync. Ignored files under a
selected mirror block unless `--autostash` preserves them.
selected mirror do not block plain sync and remain in place. If incoming tracked
content would overwrite one, Braid stops before changing the repository.

Use `--autostash` when selected mirror paths have uncommitted work that should
be carried across the sync:
Expand Down Expand Up @@ -593,9 +598,10 @@ braid remove vendor/rails --no-commit
git commit
```

Before removing, Braid requires `.braids.json` and the mirror path to be clean in
both the index and working tree. Local edits, local deletions, staged mirror
changes, and untracked files under the mirror path stop the remove.
Before removing, Braid requires `.braids.json` and the mirror path to have no
staged, tracked, or ordinary untracked changes. Ignored files do not block the
remove and remain on disk for manual cleanup after Braid removes the tracked
mirror content and configuration entry.

`braid remove --keep --no-commit` keeps the Braid-managed Git remote, but still
stages the mirror content removal and `.braids.json` update.
Expand Down
10 changes: 7 additions & 3 deletions docs/migration-from-ruby-braid.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ included in Braid's automatic commit. These commands accept `--no-commit` to
stage `.braids.json` and selected mirror paths without creating the automatic
commit. `upgrade-config --no-commit` similarly stages only `.braids.json`. The
paths owned by each command still have to be clean unless the command explicitly
supports a different flow.
supports a different flow. Ignored files under selected mirrors do not block
`pull`, `sync`, or `remove`; pull and sync preserve them, while remove leaves
them on disk after deleting tracked mirror content.

Migration impact:

Expand All @@ -88,8 +90,10 @@ Migration impact:
change belongs in the same downstream commit as other related changes.
- If unrelated files are already staged during `--no-commit`, they remain staged
and can be included in the next manual commit unless you unstage them.
- Dirty selected mirror paths still stop `add`, `pull`, `remove`, and `sync`
unless `sync --autostash` is used.
- Staged, tracked, and ordinary untracked changes in selected mirror paths still
stop `add`, `pull`, `remove`, and plain `sync`. Ignored files also stop `add`,
but not `pull`, `remove`, or plain `sync`; `sync --autostash` continues to save
and restore ignored files with the other selected-path state.
- On pull conflict, unrelated staged files remain staged. Go Braid warns about
that because a manual `git commit` after conflict resolution could include
those files unless you unstage them.
Expand Down
43 changes: 43 additions & 0 deletions integration/scoped_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,49 @@ func TestExecutableScopedRemovePreservesUnrelatedState(t *testing.T) {
assertContains(t, status, "?? untracked.txt")
}

func TestExecutableIgnoredMirrorFileSurvivesPullSyncAndRemove(t *testing.T) {
root := t.TempDir()
env := newProcessEnv(t, root)
braid := braidBinary(t)

upstream := filepath.Join(root, "upstream")
initRepo(t, env, upstream)
writeFile(t, upstream, ".gitignore", "user.bazelrc\n")
writeFile(t, upstream, "README.md", "base\n")
commitAll(t, env, upstream, "base")

downstream := filepath.Join(root, "downstream")
initRepo(t, env, downstream)
writeFile(t, downstream, "README.md", "downstream\n")
commitAll(t, env, downstream, "seed downstream")
assertResult(t, runBraid(t, env, downstream, braid, "--quiet", "add", upstream, "vendor/lib/replicant"), 0, "", "")
writeFile(t, downstream, "vendor/lib/replicant/user.bazelrc", "local config\n")

writeFile(t, upstream, "README.md", "pulled\n")
pullRevision := commitAll(t, env, upstream, "pulled")
assertResult(t, runBraid(t, env, downstream, braid, "--quiet", "pull", "vendor/lib/replicant"), 0, "", "")
assertFile(t, downstream, "vendor/lib/replicant/README.md", "pulled\n")
assertFile(t, downstream, "vendor/lib/replicant/user.bazelrc", "local config\n")
assertConfigRaw(t, downstream, map[string]configMirror{
"vendor/lib/replicant": {URL: upstream, Branch: "main", Revision: pullRevision},
})

writeFile(t, upstream, "README.md", "synced\n")
syncRevision := commitAll(t, env, upstream, "synced")
assertResult(t, runBraid(t, env, downstream, braid, "--quiet", "sync", "--pull-only", "vendor/lib/replicant"), 0, "", "")
assertFile(t, downstream, "vendor/lib/replicant/README.md", "synced\n")
assertFile(t, downstream, "vendor/lib/replicant/user.bazelrc", "local config\n")
assertConfigRaw(t, downstream, map[string]configMirror{
"vendor/lib/replicant": {URL: upstream, Branch: "main", Revision: syncRevision},
})

assertResult(t, runBraid(t, env, downstream, braid, "--quiet", "remove", "vendor/lib/replicant"), 0, "", "")
assertFile(t, downstream, "vendor/lib/replicant/user.bazelrc", "local config\n")
assertPathMissing(t, downstream, "vendor/lib/replicant/README.md")
assertPathMissing(t, downstream, "vendor/lib/replicant/.gitignore")
assertConfigRaw(t, downstream, map[string]configMirror{})
}

func TestExecutableScopedAddRemovePrecheckBlockers(t *testing.T) {
tests := []struct {
name string
Expand Down
16 changes: 16 additions & 0 deletions internal/command/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ func TestAddCommandPreservesUnrelatedIndexAndWorktreeState(t *testing.T) {
assertContains(t, status, "?? untracked.txt")
}

func TestAddCommandRejectsIgnoredContentInTargetPath(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, "README.md", "base\n")
testutil.CommitAll(t, upstream, "base")
repo := initDownstream(t)
testutil.WriteFile(t, repo, ".gitignore", "vendor/lib/replicant/user.bazelrc\n")
testutil.Git(t, repo, "add", ".gitignore")
testutil.Git(t, repo, "commit", "-m", "ignore local config")
testutil.WriteFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n")

stderr := runCommandError(t, repo, []string{"add", upstream, "vendor/lib/replicant"})

assertContains(t, stderr, "local changes are present in vendor/lib/replicant")
assertFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n")
}

func TestAddCommandNoCommitStagesContentConfigAndPreservesUnrelatedState(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, "README.md", "hello from upstream\n")
Expand Down
1 change: 1 addition & 0 deletions internal/command/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type StatusGit interface {

type UpdateGit interface {
StatusGit
IgnoredPaths(context.Context, ...string) ([]string, error)
MakeTreeWithItemIn(context.Context, string, string, gitexec.TreeItem) (string, error)
MergeTrees(context.Context, map[string]string, string, string, string) (string, error)
MergeTreeWrite(context.Context, string, string, string) (gitexec.MergeTreeResult, error)
Expand Down
18 changes: 18 additions & 0 deletions internal/command/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,24 @@ func TestPushCommandStopsWhenNotUpToDateOrNoLocalChanges(t *testing.T) {
}
assertFile(t, upstream, "README.md", "base\n")
})

t.Run("ignored local file", func(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, ".gitignore", "user.bazelrc\n")
testutil.WriteFile(t, upstream, "README.md", "base\n")
testutil.CommitAll(t, upstream, "base")
repo := initDownstream(t)
runCommandOK(t, repo, []string{"add", upstream, "vendor/lib/replicant"})
testutil.WriteFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n")

out := runCommandOK(t, repo, []string{"push", "vendor/lib/replicant"})

assertContains(t, out, "No local changes found in downstream HEAD")
if _, err := os.Stat(filepath.Join(upstream, "user.bazelrc")); !os.IsNotExist(err) {
t.Fatalf("upstream user.bazelrc stat error = %v, want not exist", err)
}
assertFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n")
})
}

func TestPushCommandDoesNotPushWhenEditorFails(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion internal/command/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func (h RemoveHandler) remove(ctx context.Context, repo RepoContext, git RemoveG
return fmt.Errorf("mirror path %q overlaps %s", path, config.FileName)
}
}
if err := ensureCommandScopesClean(ctx, git, configRoot(h.Options, repo), true, paths...); err != nil {
if err := ensureCommandScopesCleanAllowIgnored(ctx, git, configRoot(h.Options, repo), true, paths...); err != nil {
return err
}
if removeSource {
Expand Down
18 changes: 18 additions & 0 deletions internal/command/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ func TestRemoveCommandDeletesContentConfigAndRemote(t *testing.T) {
assertClean(t, repo)
}

func TestRemoveCommandLeavesIgnoredMirrorFiles(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, ".gitignore", "user.bazelrc\n")
testutil.WriteFile(t, upstream, "README.md", "base\n")
testutil.CommitAll(t, upstream, "base")

repo := initDownstream(t)
runCommandOK(t, repo, []string{"add", upstream, "vendor/lib/replicant"})
testutil.WriteFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n")

runCommandOK(t, repo, []string{"remove", "vendor/lib/replicant"})

assertFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n")
assertPathMissing(t, repo, "vendor/lib/replicant/README.md")
assertPathMissing(t, repo, "vendor/lib/replicant/.gitignore")
assertMirrorMissing(t, repo, "vendor/lib/replicant")
}

func TestRemoveCommandPreservesUnrelatedIndexAndWorktreeState(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, "README.md", "base\n")
Expand Down
2 changes: 1 addition & 1 deletion internal/command/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func (h SyncHandler) prepareSyncAutostash(ctx context.Context, repo RepoContext,
}
if !autostashEnabled {
for _, path := range paths {
if err := ensureScopedClean(ctx, git, path); err != nil {
if err := ensureScopedCleanAllowIgnored(ctx, git, path); err != nil {
return syncAutostash{}, false, err
}
}
Expand Down
29 changes: 25 additions & 4 deletions internal/command/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ func TestSyncCommandPushesChangedBranchThenUpdates(t *testing.T) {
assertNoGitRemote(t, repo, "main_braid_001")
}

func TestSyncCommandPreservesIgnoredMirrorFilesWithoutAutostash(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, ".gitignore", "user.bazelrc\n")
testutil.WriteFile(t, upstream, "README.md", "base\n")
testutil.CommitAll(t, upstream, "base")

repo := initDownstream(t)
runCommandOK(t, repo, []string{"add", upstream, "vendor/lib/replicant"})
testutil.WriteFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n")
testutil.WriteFile(t, upstream, "README.md", "updated\n")
revision := testutil.CommitAll(t, upstream, "updated")

runCommandOK(t, repo, []string{"sync", "--pull-only", "vendor/lib/replicant"})

assertFile(t, repo, "vendor/lib/replicant/README.md", "updated\n")
assertFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n")
if got := loadMirror(t, repo, "vendor/lib/replicant").Revision; got != revision {
t.Fatalf("mirror revision = %q, want %q", got, revision)
}
}

func TestSyncCommandPushesOnlyOptedInSources(t *testing.T) {
upstreamA := testutil.InitRepo(t)
testutil.WriteFile(t, upstreamA, "README.md", "a base\n")
Expand Down Expand Up @@ -495,7 +516,7 @@ func TestSyncCommandAutostashRestoresSelectedStateAndPreservesUnrelatedState(t *
}
}

func TestSyncCommandAutostashIgnoredOnlyState(t *testing.T) {
func TestSyncCommandPlainAndAutostashPreserveIgnoredOnlyState(t *testing.T) {
upstream := testutil.InitRepo(t)
testutil.WriteFile(t, upstream, "README.md", "base\n")
testutil.CommitAll(t, upstream, "base")
Expand All @@ -508,12 +529,12 @@ func TestSyncCommandAutostashIgnoredOnlyState(t *testing.T) {
testutil.WriteFile(t, upstream, "remote.txt", "remote\n")
testutil.CommitAll(t, upstream, "remote")

stderr := runCommandError(t, repo, []string{"sync", "--pull-only", "vendor/basic"})
assertContains(t, stderr, "local changes are present in vendor/basic")
runCommandOK(t, repo, []string{"sync", "--pull-only", "vendor/basic"})

assertFile(t, repo, "vendor/basic/remote.txt", "remote\n")
assertFile(t, repo, "vendor/basic/ignored.log", "ignored\n")
if stashList := strings.TrimSpace(testutil.Git(t, repo, "stash", "list").Stdout); stashList != "" {
t.Fatalf("stash list = %q, want no autostash on blocked sync", stashList)
t.Fatalf("stash list = %q, want no autostash for plain sync", stashList)
}

testutil.WriteFile(t, upstream, "another.txt", "another\n")
Expand Down
48 changes: 45 additions & 3 deletions internal/command/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ func (h UpdateHandler) updateOneWithRunOptions(ctx context.Context, repo RepoCon
return failUpdate(updateResult{}, mergeErr)
}
contentTree := mergedTree.Tree
if err := ensureIgnoredPathsNotOverwritten(ctx, git, contentTree, m.LocalPaths()...); err != nil {
_ = cleanupRemote()
return failUpdate(updateResult{}, err)
}

m.Revision = newRevision
if err := cfg.UpdateSource(m.Source); err != nil {
Expand Down Expand Up @@ -642,10 +646,18 @@ func (h UpdateHandler) ensureUpdateTargetsClean(ctx context.Context, repo RepoCo
}
}
}
return ensureCommandScopesClean(ctx, git, configRoot(h.Options, repo), true, paths...)
return ensureCommandScopesCleanAllowIgnored(ctx, git, configRoot(h.Options, repo), true, paths...)
}

func ensureCommandScopesClean(ctx context.Context, git scopedCleanGit, root string, requireConfig bool, paths ...string) error {
return ensureCommandScopesCleanWithIgnoredPolicy(ctx, git, root, requireConfig, true, paths...)
}

func ensureCommandScopesCleanAllowIgnored(ctx context.Context, git scopedCleanGit, root string, requireConfig bool, paths ...string) error {
return ensureCommandScopesCleanWithIgnoredPolicy(ctx, git, root, requireConfig, false, paths...)
}

func ensureCommandScopesCleanWithIgnoredPolicy(ctx context.Context, git scopedCleanGit, root string, requireConfig, includeIgnored bool, paths ...string) error {
if state, blocked, err := git.BlockingOperation(ctx); err != nil {
return err
} else if blocked {
Expand All @@ -655,7 +667,7 @@ func ensureCommandScopesClean(ctx context.Context, git scopedCleanGit, root stri
return err
}
for _, path := range paths {
if err := ensureScopedClean(ctx, git, path); err != nil {
if err := ensureScopedCleanWithIgnoredPolicy(ctx, git, path, includeIgnored); err != nil {
return err
}
}
Expand All @@ -674,11 +686,19 @@ func ensureConfigClean(ctx context.Context, git scopedCleanGit, root string, req
}

func ensureScopedClean(ctx context.Context, git scopedCleanGit, path string) error {
return ensureScopedCleanWithIgnoredPolicy(ctx, git, path, true)
}

func ensureScopedCleanAllowIgnored(ctx context.Context, git scopedCleanGit, path string) error {
return ensureScopedCleanWithIgnoredPolicy(ctx, git, path, false)
}

func ensureScopedCleanWithIgnoredPolicy(ctx context.Context, git scopedCleanGit, path string, includeIgnored bool) error {
var status string
var err error
if ignoredGit, ok := git.(interface {
StatusPorcelainPathspecsWithIgnored(context.Context, ...string) (string, error)
}); ok {
}); includeIgnored && ok {
status, err = ignoredGit.StatusPorcelainPathspecsWithIgnored(ctx, path)
} else {
status, err = git.StatusPorcelainPathspecs(ctx, path)
Expand All @@ -692,6 +712,28 @@ func ensureScopedClean(ctx context.Context, git scopedCleanGit, path string) err
return nil
}

func ensureIgnoredPathsNotOverwritten(ctx context.Context, git UpdateGit, treeish string, scopes ...string) error {
ignoredPaths, err := git.IgnoredPaths(ctx, scopes...)
if err != nil {
return err
}
for _, ignoredPath := range ignoredPaths {
for candidate := ignoredPath; candidate != "." && candidate != ""; candidate = filepath.Dir(candidate) {
item, itemErr := git.LsTreeItem(ctx, treeish, candidate)
if gitexec.IsTreeItemNotFound(itemErr) {
continue
}
if itemErr != nil {
return itemErr
}
if candidate == ignoredPath || item.Type != "tree" {
return fmt.Errorf("incoming tracked path %s would overwrite an ignored local file", candidate)
}
}
}
return nil
}

func mirrorOverlapsConfig(path string) bool {
clean := strings.TrimRight(path, "/")
return clean == config.FileName || strings.HasPrefix(clean, config.FileName+"/")
Expand Down
Loading