diff --git a/README.md b/README.md index 27e40e3..96fb4d5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -365,9 +366,12 @@ braid pull vendor/rails --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: @@ -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: @@ -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. diff --git a/docs/migration-from-ruby-braid.md b/docs/migration-from-ruby-braid.md index caedde6..981d405 100644 --- a/docs/migration-from-ruby-braid.md +++ b/docs/migration-from-ruby-braid.md @@ -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: @@ -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. diff --git a/integration/scoped_state_test.go b/integration/scoped_state_test.go index 886c7c8..74ffdc6 100644 --- a/integration/scoped_state_test.go +++ b/integration/scoped_state_test.go @@ -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 diff --git a/internal/command/add_test.go b/internal/command/add_test.go index 48f10ca..f020e77 100644 --- a/internal/command/add_test.go +++ b/internal/command/add_test.go @@ -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") diff --git a/internal/command/preflight.go b/internal/command/preflight.go index 0025cfa..06887b7 100644 --- a/internal/command/preflight.go +++ b/internal/command/preflight.go @@ -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) diff --git a/internal/command/push_test.go b/internal/command/push_test.go index 403c4c7..c002cf8 100644 --- a/internal/command/push_test.go +++ b/internal/command/push_test.go @@ -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) { diff --git a/internal/command/remove.go b/internal/command/remove.go index bfeabfe..54e03fb 100644 --- a/internal/command/remove.go +++ b/internal/command/remove.go @@ -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 { diff --git a/internal/command/remove_test.go b/internal/command/remove_test.go index a8935bd..7b4950c 100644 --- a/internal/command/remove_test.go +++ b/internal/command/remove_test.go @@ -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") diff --git a/internal/command/sync.go b/internal/command/sync.go index d94d8ae..325c16e 100644 --- a/internal/command/sync.go +++ b/internal/command/sync.go @@ -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 } } diff --git a/internal/command/sync_test.go b/internal/command/sync_test.go index ed0b51a..096d37a 100644 --- a/internal/command/sync_test.go +++ b/internal/command/sync_test.go @@ -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") @@ -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") @@ -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") diff --git a/internal/command/update.go b/internal/command/update.go index f63a728..d8b6bf7 100644 --- a/internal/command/update.go +++ b/internal/command/update.go @@ -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 { @@ -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 { @@ -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 } } @@ -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) @@ -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+"/") diff --git a/internal/command/update_test.go b/internal/command/update_test.go index 577424a..59c9d13 100644 --- a/internal/command/update_test.go +++ b/internal/command/update_test.go @@ -43,6 +43,53 @@ func TestUpdateCommandFastForwardsAndUsesNoVerify(t *testing.T) { } } +func TestUpdateCommandPreservesIgnoredMirrorFiles(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{"pull", "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 TestUpdateCommandRejectsIncomingTrackedPathOverIgnoredFile(t *testing.T) { + upstream := testutil.InitRepo(t) + testutil.WriteFile(t, upstream, ".gitignore", "user.bazelrc\n") + testutil.WriteFile(t, upstream, "README.md", "base\n") + baseRevision := 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, "user.bazelrc", "upstream config\n") + testutil.Git(t, upstream, "add", "-f", "user.bazelrc") + testutil.Git(t, upstream, "commit", "-m", "track config") + head := testutil.CurrentRevision(t, repo) + + stderr := runCommandError(t, repo, []string{"pull", "vendor/lib/replicant"}) + + assertContains(t, stderr, "incoming tracked path vendor/lib/replicant/user.bazelrc would overwrite an ignored local file") + assertFile(t, repo, "vendor/lib/replicant/user.bazelrc", "local config\n") + if got := testutil.CurrentRevision(t, repo); got != head { + t.Fatalf("HEAD = %q, want unchanged %q", got, head) + } + if got := loadMirror(t, repo, "vendor/lib/replicant").Revision; got != baseRevision { + t.Fatalf("mirror revision = %q, want unchanged %q", got, baseRevision) + } +} + func TestUpdateCommandAliasesUpdateSameMirror(t *testing.T) { upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index d557fec..b8e11e1 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -316,6 +316,20 @@ func (g Git) StatusPorcelainPathspecsWithIgnored(ctx context.Context, pathspecs return result.Stdout, nil } +func (g Git) IgnoredPaths(ctx context.Context, pathspecs ...string) ([]string, error) { + args := []string{"ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--"} + args = append(args, pathspecs...) + result, err := g.RunOK(ctx, args...) + if err != nil { + return nil, err + } + output := strings.TrimSuffix(result.Stdout, "\x00") + if output == "" { + return nil, nil + } + return strings.Split(output, "\x00"), nil +} + func (g Git) StashPushAllPathspecs(ctx context.Context, message string, pathspecs ...string) (StashEntry, bool, error) { if message == "" { return StashEntry{}, false, errors.New("stash message is required") diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index f79ebcc..81f42e8 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -461,6 +461,26 @@ func TestStatusPorcelainPathspecsWithIgnoredReportsIgnoredOnlyState(t *testing.T } } +func TestIgnoredPathsReturnsOnlyIgnoredFilesInScope(t *testing.T) { + repo := initRealRepo(t) + writeRealFile(t, repo, ".gitignore", "target/*.log\noutside.log\n") + writeRealFile(t, repo, "target/tracked.txt", "base\n") + realGit(t, repo, "add", ".") + realGit(t, repo, "commit", "-m", "base") + writeRealFile(t, repo, "target/ignored file.log", "ignored\n") + writeRealFile(t, repo, "target/untracked.txt", "untracked\n") + writeRealFile(t, repo, "outside.log", "outside ignored\n") + + paths, err := New(repo, false, nil).IgnoredPaths(context.Background(), "target") + if err != nil { + t.Fatalf("IgnoredPaths returned error: %v", err) + } + want := []string{"target/ignored file.log"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("IgnoredPaths = %#v, want %#v", paths, want) + } +} + func TestStashPushAllPathspecsRestoresSelectedStateAndPreservesUnrelatedState(t *testing.T) { repo := initRealRepo(t) writeRealFile(t, repo, ".gitignore", "mirror/ignored.log\noutside-ignored.log\n")