diff --git a/CONTEXT.md b/CONTEXT.md index f689295..2f5a919 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,6 +36,10 @@ _Avoid_: Workspace Mode, multi-repository mode The existing default layout where a repository's linked worktrees live next to the main worktree using names like `-wt-`. _Avoid_: Flat mode +**Copy Pattern**: +A user-configured gitignore-style pattern that selects ignored files or ignored directory descendants to copy from a source Repository Worktree into a target Repository Worktree at the same Git-root-relative path. +_Avoid_: Recursive copy entry, exact copy entry + ## Example Dialogue Dev: "I'm working on a feature that touches API, web, and SDK." diff --git a/Cargo.lock b/Cargo.lock index 75d0b8b..2c98023 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -11,6 +20,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e308e67087593070530a666f7fe8815cbd2e61d8f5b7313b71b7d721d1dfde" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -62,6 +81,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -90,6 +122,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.1" @@ -114,6 +152,23 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ryu" version = "1.0.23" @@ -285,6 +340,7 @@ dependencies = [ name = "wtk" version = "0.5.1" dependencies = [ + "globset", "serde", "serde_json", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index 09f95c2..1824769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,4 @@ serde_json = "1.0" serde_yaml = "0.9" sha2 = "0.10" toml = "0.8" +globset = "0.4" diff --git a/README.md b/README.md index a8d755e..d05e906 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ wtk new feature/login wtk checkout feature/existing ``` -By default, `wtk` creates sibling worktree directories named like `-wt-`. It also copies ignored local config files that usually need to exist before the worktree is usable, including `.env` files by exact name and ignored descendants under `.agents/`, and it runs `pnpm install` for pnpm repositories. Copy behavior can be customized in repo-local `.wtk/config.toml` or global `~/.wtk/config.toml`. +By default, `wtk` creates sibling worktree directories named like `-wt-`. It can copy ignored local config files selected by global `~/.wtk/config.toml` Copy Patterns (for example `**/.env` and `.agents/` from the default template), and it runs `pnpm install` for pnpm repositories. Repo-local `.wtk/config.toml` cannot configure `copy`. ### Move work out after you already started diff --git a/docs/user-guide.md b/docs/user-guide.md index 21da81a..b85ec25 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -48,14 +48,15 @@ wtk checkout feature/existing By default, `wtk` uses the Sibling Layout: a repository's linked worktrees live next to the main worktree using names like `-wt-`. -Commands that create linked worktrees copy configured ignored files from the main worktree into the new worktree at the same Git-root-relative paths. The default recursive file-name list is `.env`, and the default exact-path list is `.agents`. Exact-path directory entries only copy files and symlinks that Git reports as ignored under that directory, so tracked files under `.agents/` are not copied unless you configure them some other way. Recursive matching is still exact-name based, so files such as `.env.local`, `.env.example`, and `.envrc` are not copied unless configured explicitly. When matching ignored files are copied, `wtk` prints one line per file, such as `copied ignored .env: ` or `copied ignored file: .agents/local.md`. +Commands that create linked worktrees copy globally configured ignored files from the main worktree into the new worktree at the same Git-root-relative paths. Copy Patterns use gitignore-style syntax except negation (`!`) and only select files or symlinks that Git reports as ignored; tracked files are never copied by Copy Patterns. When matching ignored files are copied, `wtk` prints one concise summary such as `copied 12 ignored files`. -Copy behavior can be configured in either repo-local `.wtk/config.toml` or global `~/.wtk/config.toml`. Repo-local config overrides global config for each configured list. The shape is: +Copy Patterns are configured only in global `~/.wtk/config.toml`. Repo-local `.wtk/config.toml` cannot contain `copy` and fails fast if it does. Without a global `copy` list, `wtk` copies no ignored files by default. The default config template is: ```toml -[copy] -recursive = [".env"] -exact = [".agents"] +copy = [ + "**/.env", + ".agents/", +] ``` For standalone `wtk new`, success is reported before asynchronous worktree initialization finishes copying ignored files into the new worktree. `wtk` prints info lines when that background initialization starts and where its temporary log file is written. diff --git a/e2e/README.md b/e2e/README.md index 1e9c448..ba2f9cb 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -4,7 +4,7 @@ This suite runs black-box end-to-end tests for `wtk` with real: - Git repositories and linked worktrees - Auxiliary Group configuration, coordinated worktrees, and generated refs -- ignored recursive file copying such as `.env` and `secrets.auto.tfvars` +- ignored file Copy Patterns such as `**/.env` and `**/secrets.auto.tfvars` - `pnpm install` flows Run it from the repository root: diff --git a/e2e/test_auxiliary_group.py b/e2e/test_auxiliary_group.py index 628d399..9f2a793 100644 --- a/e2e/test_auxiliary_group.py +++ b/e2e/test_auxiliary_group.py @@ -134,6 +134,27 @@ def test_auxiliary_group_list_and_remove_manage_config(run_wtk, repo_factory) -> assert run_wtk("ag", "list", cwd=primary).stdout == "No Auxiliary Groups configured.\n" +def test_auxiliary_group_add_rejects_repo_local_copy(run_wtk, repo_factory) -> None: + primary = repo_factory.init_repo("primary") + api = repo_factory.init_repo("api") + (primary / ".wtk").mkdir() + (primary / ".wtk" / "config.toml").write_text( + 'copy = ["**/.env"]\n', encoding="utf-8" + ) + + result = run_wtk( + "auxiliary-group", + "add", + "backend", + str(api), + cwd=primary, + check=False, + ) + + result.assert_failure() + assert "Copy Patterns are supported only in global ~/.wtk/config.toml" in result.output + + def test_auxiliary_group_remove_rejects_unknown_group(run_wtk, repo_factory) -> None: primary = repo_factory.init_repo("primary") @@ -391,10 +412,6 @@ def test_auxiliary_group_new_dedupes_primary_recursive_and_exact_symlink_copy( primary, { ".gitignore": "apps/web/.env\n", - ".wtk/config.toml": """ -[copy] -exact = ["apps/web/.env"] -""".lstrip(), "apps/web/keep.txt": "web\n", }, "configure duplicate coordinated copy", @@ -404,6 +421,9 @@ def test_auxiliary_group_new_dedupes_primary_recursive_and_exact_symlink_copy( shared_env = tmp_path / "shared.env" shared_env.write_text("WEB=value\n", encoding="utf-8") os.symlink(shared_env, primary / "apps" / "web" / ".env") + home = tmp_path / "home" + (home / ".wtk").mkdir(parents=True) + (home / ".wtk" / "config.toml").write_text('copy = ["**/.env", "apps/web/.env"]\n', encoding="utf-8") out = run_wtk( "new", @@ -413,12 +433,14 @@ def test_auxiliary_group_new_dedupes_primary_recursive_and_exact_symlink_copy( "--from-current", "--no-clipboard", cwd=primary, + env={"HOME": str(home)}, ).output primary_linked = linked_worktree_path(primary, "feature/aux-deduped") assert primary_linked.joinpath("apps/web/.env").is_symlink() assert primary_linked.joinpath("apps/web/.env").resolve() == shared_env.resolve() - assert out.count("apps/web/.env") == 1 + assert "copied 1 ignored files" in out + assert out.count("apps/web/.env") == 0 def test_auxiliary_group_new_rejects_base_with_from_current(run_wtk, repo_factory) -> None: @@ -916,9 +938,10 @@ def test_auxiliary_group_add_does_not_persist_inherited_global_copy_config( (home / ".wtk").mkdir(parents=True) (home / ".wtk" / "config.toml").write_text( """ -[copy] -recursive = [".env.local"] -exact = ["specs/change/active"] +copy = [ + "**/.env.local", + "specs/change/active", +] """.lstrip(), encoding="utf-8", ) @@ -928,7 +951,7 @@ def test_auxiliary_group_add_does_not_persist_inherited_global_copy_config( config_text = (primary / ".wtk" / "config.toml").read_text(encoding="utf-8") assert "[auxiliary-groups.backend]" in config_text assert "[auxiliaries.api]" in config_text - assert "[copy]" not in config_text + assert "copy" not in config_text assert ".env.local" not in config_text assert "specs/change/active" not in config_text diff --git a/e2e/test_env_copy.py b/e2e/test_env_copy.py index cf730e1..97f66ef 100644 --- a/e2e/test_env_copy.py +++ b/e2e/test_env_copy.py @@ -9,7 +9,13 @@ from conftest import run_git -def test_root_and_nested_ignored_env_files_copy(run_wtk, repo_factory) -> None: +def write_global_copy_config(home, patterns): + (home / ".wtk").mkdir(parents=True) + body = "copy = [\n" + "".join(f' "{pattern}",\n' for pattern in patterns) + "]\n" + (home / ".wtk" / "config.toml").write_text(body, encoding="utf-8") + + +def test_root_and_nested_ignored_env_files_copy(run_wtk, repo_factory, tmp_path) -> None: repo = repo_factory.init_repo("repo") repo_factory.commit_files( repo, @@ -23,8 +29,10 @@ def test_root_and_nested_ignored_env_files_copy(run_wtk, repo_factory) -> None: (repo / ".env").write_text("ROOT=value\n", encoding="utf-8") (repo / "apps" / "web" / ".env").write_text("WEB=value\n", encoding="utf-8") (repo / "services" / "api" / ".env").write_text("API=value\n", encoding="utf-8") + home = tmp_path / "home" + write_global_copy_config(home, ["**/.env", ".agents/"]) - out = run_wtk("new", "feature/envs", "--base", "main", "--no-clipboard", cwd=repo).output + out = run_wtk("new", "feature/envs", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output linked = linked_worktree_path(repo, "feature/envs") wait_until("copied env files", lambda: linked.joinpath(".env").exists() and linked.joinpath("apps/web/.env").exists()) @@ -32,6 +40,8 @@ def test_root_and_nested_ignored_env_files_copy(run_wtk, repo_factory) -> None: assert linked.joinpath("apps/web/.env").read_text(encoding="utf-8") == "WEB=value\n" assert linked.joinpath("services/api/.env").read_text(encoding="utf-8") == "API=value\n" assert "initializing worktree asynchronously" in out + assert "copied 3 ignored files" in out + assert "copied ignored" not in out def test_root_and_nested_ignored_secrets_auto_tfvars_copy( @@ -58,9 +68,9 @@ def test_root_and_nested_ignored_secrets_auto_tfvars_copy( (home / ".wtk").mkdir(parents=True) (home / ".wtk" / "config.toml").write_text( """ -[copy] -recursive = ["secrets.auto.tfvars"] -exact = [] +copy = [ + "**/secrets.auto.tfvars", +] """.lstrip(), encoding="utf-8", ) @@ -94,7 +104,7 @@ def test_root_and_nested_ignored_secrets_auto_tfvars_copy( assert "initializing worktree asynchronously" in out -def test_ignored_only_dirs_non_ascii_and_tracked_env_behavior(run_wtk, repo_factory) -> None: +def test_ignored_only_dirs_non_ascii_and_tracked_env_behavior(run_wtk, repo_factory, tmp_path) -> None: repo = repo_factory.init_repo("repo") repo_factory.commit_files( repo, @@ -108,8 +118,10 @@ def test_ignored_only_dirs_non_ascii_and_tracked_env_behavior(run_wtk, repo_fact (repo / "secrets" / ".env").write_text("SECRET=value\n", encoding="utf-8") (repo / "café").mkdir() (repo / "café" / ".env").write_text("UNICODE=value\n", encoding="utf-8") + home = tmp_path / "home" + write_global_copy_config(home, ["**/.env"]) - out = run_wtk("new", "feature/mixed", "--base", "main", "--no-clipboard", cwd=repo).output + out = run_wtk("new", "feature/mixed", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output linked = linked_worktree_path(repo, "feature/mixed") wait_until("unicode env copy", lambda: linked.joinpath("café/.env").exists()) @@ -127,8 +139,10 @@ def test_symlink_and_permissions_are_preserved(run_wtk, repo_factory, tmp_path) shared_env = tmp_path / "shared.env" shared_env.write_text("ROOT=value\n", encoding="utf-8") os.symlink(shared_env, repo / ".env") + home = tmp_path / "home" + write_global_copy_config(home, ["**/.env"]) - run_wtk("new", "feature/root-env-symlink", "--base", "main", "--no-clipboard", cwd=repo) + run_wtk("new", "feature/root-env-symlink", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}) linked = linked_worktree_path(repo, "feature/root-env-symlink") wait_until("symlink copy", lambda: linked.joinpath(".env").exists()) assert linked.joinpath(".env").is_symlink() @@ -139,7 +153,7 @@ def test_symlink_and_permissions_are_preserved(run_wtk, repo_factory, tmp_path) (repo / ".env").write_text("ROOT=value\n", encoding="utf-8") os.chmod(repo / ".env", 0o600) - run_wtk("new", "feature/root-env-mode", "--base", "main", "--no-clipboard", cwd=repo) + run_wtk("new", "feature/root-env-mode", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}) linked = linked_worktree_path(repo, "feature/root-env-mode") wait_until( "env permissions", @@ -189,7 +203,7 @@ def test_similarly_named_tfvars_files_are_not_copied(run_wtk, repo_factory) -> N assert not linked.joinpath("config/secrets.auto.tfvars.tpl").exists() -def test_global_copy_config_controls_recursive_and_exact_files( +def test_global_copy_config_controls_copy_patterns( run_wtk, repo_factory, tmp_path ) -> None: repo = repo_factory.init_repo("repo") @@ -209,9 +223,10 @@ def test_global_copy_config_controls_recursive_and_exact_files( (home / ".wtk").mkdir(parents=True) (home / ".wtk" / "config.toml").write_text( """ -[copy] -recursive = [".env.local"] -exact = ["notes/secret.txt"] +copy = [ + "**/.env.local", + "notes/secret.txt", +] """.lstrip(), encoding="utf-8", ) @@ -240,22 +255,128 @@ def test_global_copy_config_controls_recursive_and_exact_files( ) -def test_default_exact_agents_directory_skips_missing_directory( - run_wtk, repo_factory +def test_invalid_copy_pattern_fails_before_worktree_creation( + run_wtk, repo_factory, tmp_path +) -> None: + repo = repo_factory.init_repo("repo") + repo_factory.commit_files(repo, {".gitignore": ".env\n"}, "ignore env") + (repo / ".env").write_text("ROOT=value\n", encoding="utf-8") + home = tmp_path / "home" + write_global_copy_config(home, ["["]) + + result = run_wtk( + "new", + "feature/bad-copy-pattern", + "--base", + "main", + "--no-clipboard", + cwd=repo, + env={"HOME": str(home)}, + check=False, + ) + linked = linked_worktree_path(repo, "feature/bad-copy-pattern") + + result.assert_failure() + assert "invalid copy pattern" in result.output + assert not linked.exists() + + +def test_negated_copy_pattern_fails_before_worktree_creation( + run_wtk, repo_factory, tmp_path +) -> None: + repo = repo_factory.init_repo("repo") + repo_factory.commit_files(repo, {".gitignore": "secrets/\n"}, "ignore secrets") + (repo / "secrets").mkdir() + (repo / "secrets" / "token.txt").write_text("SECRET=value\n", encoding="utf-8") + home = tmp_path / "home" + write_global_copy_config(home, ["secrets/**", "!secrets/public/**"]) + + result = run_wtk( + "new", + "feature/negated-copy-pattern", + "--base", + "main", + "--no-clipboard", + cwd=repo, + env={"HOME": str(home)}, + check=False, + ) + linked = linked_worktree_path(repo, "feature/negated-copy-pattern") + + result.assert_failure() + assert "copy entries do not support negation patterns" in result.output + assert not linked.exists() + + +def test_no_runtime_defaults_skip_ignored_copy( + run_wtk, repo_factory, tmp_path ) -> None: repo = repo_factory.init_repo("repo") repo_factory.commit_files(repo, {".gitignore": ".env\n"}, "ignore env only") - out = run_wtk("new", "feature/missing-agents", "--base", "main", "--no-clipboard", cwd=repo).output + (repo / ".env").write_text("ROOT=value\n", encoding="utf-8") + home = tmp_path / "home" + + out = run_wtk("new", "feature/missing-agents", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output linked = linked_worktree_path(repo, "feature/missing-agents") + assert linked.exists() + assert not linked.joinpath(".env").exists() + assert "copied " not in out + + +def test_globbed_directory_copy_pattern_matches_ignored_descendants( + run_wtk, repo_factory, tmp_path +) -> None: + repo = repo_factory.init_repo("repo") + repo_factory.commit_files( + repo, + { + ".gitignore": "apps/web/.cache/\napps/api/.cache/\n", + "apps/web/keep.txt": "web\n", + "apps/api/keep.txt": "api\n", + }, + "ignore globbed cache dirs", + ) + (repo / "apps" / "web" / ".cache").mkdir() + (repo / "apps" / "web" / ".cache" / "token.txt").write_text("WEB\n", encoding="utf-8") + (repo / "apps" / "api" / ".cache").mkdir() + (repo / "apps" / "api" / ".cache" / "token.txt").write_text("API\n", encoding="utf-8") + home = tmp_path / "home" + write_global_copy_config(home, ["apps/*/.cache/"]) + + out = run_wtk("new", "feature/globbed-dir", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output + linked = linked_worktree_path(repo, "feature/globbed-dir") + wait_until( + "globbed directory copied", + lambda: linked.joinpath("apps/web/.cache/token.txt").exists() + and linked.joinpath("apps/api/.cache/token.txt").exists(), + ) + + assert linked.joinpath("apps/web/.cache/token.txt").read_text(encoding="utf-8") == "WEB\n" + assert linked.joinpath("apps/api/.cache/token.txt").read_text(encoding="utf-8") == "API\n" + assert "copied 2 ignored files" in out + + +def test_directory_copy_pattern_does_not_match_file_with_same_name( + run_wtk, repo_factory, tmp_path +) -> None: + repo = repo_factory.init_repo("repo") + repo_factory.commit_files(repo, {".gitignore": ".agents\n"}, "ignore agents file") + (repo / ".agents").write_text("not a dir\n", encoding="utf-8") + home = tmp_path / "home" + write_global_copy_config(home, [".agents/"]) + + out = run_wtk("new", "feature/agents-file", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output + linked = linked_worktree_path(repo, "feature/agents-file") + assert linked.exists() assert not linked.joinpath(".agents").exists() - assert "copied ignored file:" not in out + assert "copied " not in out @pytest.mark.skipif(os.name == "nt", reason="requires unix symlink semantics") -def test_default_exact_agents_directory_copies_ignored_files_and_symlinks( +def test_copy_pattern_agents_directory_copies_ignored_files_and_symlinks( run_wtk, repo_factory, tmp_path ) -> None: repo = repo_factory.init_repo("repo") @@ -271,8 +392,10 @@ def test_default_exact_agents_directory_copies_ignored_files_and_symlinks( shared = tmp_path / "shared-agents.txt" shared.write_text("SHARED=1\n", encoding="utf-8") os.symlink(shared, repo / ".agents" / "shared.txt") + home = tmp_path / "home" + write_global_copy_config(home, [".agents/"]) - out = run_wtk("new", "feature/agents-dir", "--base", "main", "--no-clipboard", cwd=repo).output + out = run_wtk("new", "feature/agents-dir", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output linked = linked_worktree_path(repo, "feature/agents-dir") wait_until( "agents directory copied", @@ -286,8 +409,48 @@ def test_default_exact_agents_directory_copies_ignored_files_and_symlinks( assert "initializing worktree asynchronously" in out -def test_default_exact_agents_directory_only_copies_ignored_descendants( - run_wtk, repo_factory +def test_copy_pattern_agents_directory_matches_nested_ignored_directories( + run_wtk, repo_factory, tmp_path +) -> None: + repo = repo_factory.init_repo("repo") + repo_factory.commit_files( + repo, + { + ".gitignore": ".agents/\n", + }, + "ignore agents directories", + ) + (repo / "nested" / ".agents").mkdir(parents=True) + (repo / "nested" / ".agents" / "instructions.md").write_text( + "NESTED=1\n", encoding="utf-8" + ) + home = tmp_path / "home" + write_global_copy_config(home, [".agents/"]) + + out = run_wtk( + "new", + "feature/nested-agents-dir", + "--base", + "main", + "--no-clipboard", + cwd=repo, + env={"HOME": str(home)}, + ).output + linked = linked_worktree_path(repo, "feature/nested-agents-dir") + wait_until( + "nested agents directory copied", + lambda: linked.joinpath("nested/.agents/instructions.md").exists(), + ) + + assert ( + linked.joinpath("nested/.agents/instructions.md").read_text(encoding="utf-8") + == "NESTED=1\n" + ) + assert "copied 1 ignored files" in out + + +def test_copy_pattern_agents_directory_only_copies_ignored_descendants( + run_wtk, repo_factory, tmp_path ) -> None: repo = repo_factory.init_repo("repo") repo_factory.commit_files( @@ -303,8 +466,10 @@ def test_default_exact_agents_directory_only_copies_ignored_descendants( (repo / ".agents" / "local.md").write_text("LOCAL=1\n", encoding="utf-8") (repo / ".agents" / "nested").mkdir() (repo / ".agents" / "nested" / "private.txt").write_text("PRIVATE=1\n", encoding="utf-8") + home = tmp_path / "home" + write_global_copy_config(home, [".agents/"]) - out = run_wtk("new", "feature/agents-partial", "--base", "main", "--no-clipboard", cwd=repo).output + out = run_wtk("new", "feature/agents-partial", "--base", "main", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output linked = linked_worktree_path(repo, "feature/agents-partial") wait_until( "partial agents directory copied", @@ -325,7 +490,82 @@ def test_default_exact_agents_directory_only_copies_ignored_descendants( assert "initializing worktree asynchronously" in out -def test_repo_local_copy_config_overrides_global_copy_config( +def test_slashless_copy_pattern_matches_ignored_directory_descendants( + run_wtk, repo_factory, tmp_path +) -> None: + repo = repo_factory.init_repo("repo") + repo_factory.commit_files( + repo, + { + ".gitignore": "secrets/\n", + }, + "ignore secrets directory", + ) + (repo / "secrets").mkdir() + (repo / "secrets" / "token").write_text("SECRET=1\n", encoding="utf-8") + home = tmp_path / "home" + write_global_copy_config(home, ["secrets"]) + + out = run_wtk( + "new", + "feature/secrets-dir", + "--base", + "main", + "--no-clipboard", + cwd=repo, + env={"HOME": str(home)}, + ).output + linked = linked_worktree_path(repo, "feature/secrets-dir") + wait_until( + "slashless secrets directory copied", + lambda: linked.joinpath("secrets/token").exists(), + ) + + assert linked.joinpath("secrets/token").read_text(encoding="utf-8") == "SECRET=1\n" + assert "copied 1 ignored files" in out + + +def test_copy_pattern_preserves_ignored_descendants_for_slash_path_directories( + run_wtk, repo_factory, tmp_path +) -> None: + repo = repo_factory.init_repo("repo") + repo_factory.commit_files( + repo, + { + ".gitignore": "specs/change/active\n", + }, + "ignore active spec directory", + ) + (repo / "specs" / "change" / "active").mkdir(parents=True) + (repo / "specs" / "change" / "active" / "plan.md").write_text( + "ACTIVE\n", encoding="utf-8" + ) + home = tmp_path / "home" + write_global_copy_config(home, ["specs/change/active"]) + + out = run_wtk( + "new", + "feature/active-spec", + "--base", + "main", + "--no-clipboard", + cwd=repo, + env={"HOME": str(home)}, + ).output + linked = linked_worktree_path(repo, "feature/active-spec") + wait_until( + "slash path directory descendants copied", + lambda: linked.joinpath("specs/change/active/plan.md").exists(), + ) + + assert ( + linked.joinpath("specs/change/active/plan.md").read_text(encoding="utf-8") + == "ACTIVE\n" + ) + assert "copied 1 ignored files" in out + + +def test_repo_local_copy_config_is_rejected( run_wtk, repo_factory, tmp_path ) -> None: repo = repo_factory.init_repo("repo") @@ -345,23 +585,22 @@ def test_repo_local_copy_config_overrides_global_copy_config( (home / ".wtk").mkdir(parents=True) (home / ".wtk" / "config.toml").write_text( """ -[copy] -recursive = [".env.local"] -exact = ["specs/change/active"] +copy = [ + "**/.env.local", + "specs/change/active", +] """.lstrip(), encoding="utf-8", ) (repo / ".wtk").mkdir() (repo / ".wtk" / "config.toml").write_text( """ -[copy] -recursive = [".env"] -exact = [] +copy = ["**/.env"] """.lstrip(), encoding="utf-8", ) - run_wtk( + result = run_wtk( "new", "feature/local-override", "--base", @@ -369,17 +608,17 @@ def test_repo_local_copy_config_overrides_global_copy_config( "--no-clipboard", cwd=repo, env={"HOME": str(home)}, + check=False, ) linked = linked_worktree_path(repo, "feature/local-override") - wait_until("local override recursive copy", lambda: linked.joinpath(".env").exists()) + result.assert_failure() - assert linked.joinpath(".env").read_text(encoding="utf-8") == "DEFAULT=value\n" - assert not linked.joinpath(".env.local").exists() - assert not linked.joinpath("specs/change/active").exists() + assert "Copy Patterns are supported only in global ~/.wtk/config.toml" in result.output + assert not linked.exists() @pytest.mark.skipif(os.name == "nt", reason="requires unix symlink semantics") -def test_exact_copy_config_dedupes_recursive_symlink_snapshot( +def test_overlapping_copy_patterns_dedupe_symlink_snapshot( run_wtk, repo_factory, tmp_path ) -> None: repo = repo_factory.init_repo("repo") @@ -400,13 +639,15 @@ def test_exact_copy_config_dedupes_recursive_symlink_snapshot( (home / ".wtk").mkdir(parents=True) (home / ".wtk" / "config.toml").write_text( """ -[copy] -exact = ["apps/web/.env"] +copy = [ + "**/.env", + "apps/web/.env", +] """.lstrip(), encoding="utf-8", ) - run_wtk( + out = run_wtk( "new", "feature/deduped-symlink", "--base", @@ -414,9 +655,11 @@ def test_exact_copy_config_dedupes_recursive_symlink_snapshot( "--no-clipboard", cwd=repo, env={"HOME": str(home)}, - ) + ).output linked = linked_worktree_path(repo, "feature/deduped-symlink") wait_until("deduped symlink copy", lambda: linked.joinpath("apps/web/.env").exists()) assert linked.joinpath("apps/web/.env").is_symlink() assert linked.joinpath("apps/web/.env").resolve() == shared_env.resolve() + assert "copied 1 ignored files" in out + assert out.count("apps/web/.env") == 0 diff --git a/e2e/test_pnpm.py b/e2e/test_pnpm.py index 149fa40..f77be01 100644 --- a/e2e/test_pnpm.py +++ b/e2e/test_pnpm.py @@ -46,7 +46,7 @@ def test_send_out_returns_before_slow_real_pnpm_install_finishes(run_wtk, repo_f wait_until("slow send-out pnpm marker", lambda: linked.joinpath(".pnpm-send-out.txt").exists(), timeout=15.0) -def test_auxiliary_group_new_copies_env_and_runs_real_pnpm_install(run_wtk, repo_factory) -> None: +def test_auxiliary_group_new_copies_env_and_runs_real_pnpm_install(run_wtk, repo_factory, tmp_path) -> None: primary = repo_factory.init_repo("primary") members = { "A": repo_factory.init_repo("A"), @@ -58,6 +58,9 @@ def test_auxiliary_group_new_copies_env_and_runs_real_pnpm_install(run_wtk, repo repo_factory.commit_files(repo, {".gitignore": ".env\n"}, "ignore env") (repo / ".env").write_text(f"{name}=value\n", encoding="utf-8") repo_factory.add_real_pnpm_project(repo, marker_name=f".pnpm-{name.lower()}.txt") + home = tmp_path / "home" + (home / ".wtk").mkdir(parents=True) + (home / ".wtk" / "config.toml").write_text('copy = ["**/.env"]\n', encoding="utf-8") out = run_wtk( "new", @@ -68,6 +71,7 @@ def test_auxiliary_group_new_copies_env_and_runs_real_pnpm_install(run_wtk, repo "full-stack", "--no-clipboard", cwd=primary, + env={"HOME": str(home)}, ).output primary_linked = linked_worktree_path(primary, "feature/aux-init") linked_a = linked_worktree_path(members["A"], "feature/aux-init") @@ -77,12 +81,12 @@ def test_auxiliary_group_new_copies_env_and_runs_real_pnpm_install(run_wtk, repo wait_until("auxiliary pnpm markers", lambda: linked_a.joinpath(".pnpm-a.txt").exists() and linked_b.joinpath(".pnpm-b.txt").exists(), timeout=15.0) assert linked_a.joinpath(".env").read_text(encoding="utf-8") == "A=value\n" assert linked_b.joinpath(".env").read_text(encoding="utf-8") == "B=value\n" - assert "copied ignored .env: .env" in out + assert "copied 1 ignored files" in out assert "running pnpm install asynchronously" in out def test_auxiliary_group_new_returns_before_slow_real_pnpm_install_finishes( - run_wtk, repo_factory + run_wtk, repo_factory, tmp_path ) -> None: primary = repo_factory.init_repo("primary") members = { @@ -97,6 +101,9 @@ def test_auxiliary_group_new_returns_before_slow_real_pnpm_install_finishes( repo_factory.add_real_pnpm_project( repo, delay_seconds=2.0, marker_name=f".pnpm-slow-{name.lower()}.txt" ) + home = tmp_path / "home" + (home / ".wtk").mkdir(parents=True) + (home / ".wtk" / "config.toml").write_text('copy = ["**/.env"]\n', encoding="utf-8") started = time.monotonic() out = run_wtk( @@ -108,6 +115,7 @@ def test_auxiliary_group_new_returns_before_slow_real_pnpm_install_finishes( "full-stack", "--no-clipboard", cwd=primary, + env={"HOME": str(home)}, ).output elapsed = time.monotonic() - started diff --git a/e2e/test_repo_mode.py b/e2e/test_repo_mode.py index d57baeb..e7c6004 100644 --- a/e2e/test_repo_mode.py +++ b/e2e/test_repo_mode.py @@ -40,29 +40,27 @@ def test_repo_mode_create_remove_send_out_bring_in_and_completion(run_wtk, repo_ assert "wtk" in run_wtk("completion", shell, cwd=repo).stdout -def test_send_out_copies_configured_exact_files(run_wtk, repo_factory) -> None: +def test_send_out_copies_configured_exact_files(run_wtk, repo_factory, tmp_path) -> None: repo = repo_factory.init_repo("repo") repo_factory.commit_files( repo, { ".gitignore": "specs/change/active\n", - ".wtk/config.toml": """ -[copy] -recursive = [] -exact = ["specs/change/active"] -""".lstrip(), }, "configure exact copy", ) (repo / "specs" / "change").mkdir(parents=True) (repo / "specs" / "change" / "active").write_text("ACTIVE\n", encoding="utf-8") + home = tmp_path / "home" + (home / ".wtk").mkdir(parents=True) + (home / ".wtk" / "config.toml").write_text('copy = ["specs/change/active"]\n', encoding="utf-8") run_git(repo, "switch", "-c", "feature/send-spec") - out = run_wtk("send-out", "--no-clipboard", cwd=repo).output + out = run_wtk("send-out", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output linked = linked_worktree_path(repo, "feature/send-spec") assert linked.joinpath("specs/change/active").read_text(encoding="utf-8") == "ACTIVE\n" - assert "copied ignored file: specs/change/active" in out + assert "copied 1 ignored files" in out @pytest.mark.skipif(os.name == "nt", reason="requires unix symlink semantics") @@ -74,10 +72,6 @@ def test_send_out_dedupes_recursive_and_exact_symlink_copy( repo, { ".gitignore": "apps/web/.env\n", - ".wtk/config.toml": """ -[copy] -exact = ["apps/web/.env"] -""".lstrip(), "apps/web/keep.txt": "web\n", }, "configure duplicate send-out copy", @@ -86,14 +80,18 @@ def test_send_out_dedupes_recursive_and_exact_symlink_copy( shared_env = tmp_path / "shared.env" shared_env.write_text("WEB=value\n", encoding="utf-8") os.symlink(shared_env, repo / "apps" / "web" / ".env") + home = tmp_path / "home" + (home / ".wtk").mkdir(parents=True) + (home / ".wtk" / "config.toml").write_text('copy = ["**/.env", "apps/web/.env"]\n', encoding="utf-8") run_git(repo, "switch", "-c", "feature/deduped-send-out") - out = run_wtk("send-out", "--no-clipboard", cwd=repo).output + out = run_wtk("send-out", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output linked = linked_worktree_path(repo, "feature/deduped-send-out") assert linked.joinpath("apps/web/.env").is_symlink() assert linked.joinpath("apps/web/.env").resolve() == shared_env.resolve() - assert out.count("apps/web/.env") == 1 + assert "copied 1 ignored files" in out + assert out.count("apps/web/.env") == 0 @pytest.mark.skipif(os.name == "nt", reason="requires unix symlink semantics") @@ -105,10 +103,6 @@ def test_checkout_dedupes_recursive_and_exact_symlink_copy( repo, { ".gitignore": "apps/web/.env\n", - ".wtk/config.toml": """ -[copy] -exact = ["apps/web/.env"] -""".lstrip(), "apps/web/keep.txt": "web\n", }, "configure duplicate checkout copy", @@ -117,14 +111,18 @@ def test_checkout_dedupes_recursive_and_exact_symlink_copy( shared_env = tmp_path / "shared.env" shared_env.write_text("WEB=value\n", encoding="utf-8") os.symlink(shared_env, repo / "apps" / "web" / ".env") + home = tmp_path / "home" + (home / ".wtk").mkdir(parents=True) + (home / ".wtk" / "config.toml").write_text('copy = ["**/.env", "apps/web/.env"]\n', encoding="utf-8") run_git(repo, "branch", "feature/deduped-checkout") - out = run_wtk("checkout", "feature/deduped-checkout", "--no-clipboard", cwd=repo).output + out = run_wtk("checkout", "feature/deduped-checkout", "--no-clipboard", cwd=repo, env={"HOME": str(home)}).output linked = linked_worktree_path(repo, "feature/deduped-checkout") assert linked.joinpath("apps/web/.env").is_symlink() assert linked.joinpath("apps/web/.env").resolve() == shared_env.resolve() - assert out.count("apps/web/.env") == 1 + assert "copied 1 ignored files" in out + assert out.count("apps/web/.env") == 0 def test_repo_mode_status_and_list_readable(run_wtk, repo_factory) -> None: diff --git a/scripts/default-config.toml b/scripts/default-config.toml index fe3e7d1..3dfca69 100644 --- a/scripts/default-config.toml +++ b/scripts/default-config.toml @@ -1,6 +1,4 @@ -[copy] -# Recursively copy ignored files with these exact file names from the main worktree. -recursive = [".env"] - -# Copy ignored files at these exact Git-root-relative paths. -exact = [".agents"] +copy = [ + "**/.env", + ".agents/", +] diff --git a/specs/change/20260625-copy-gitignore-patterns/design.md b/specs/change/20260625-copy-gitignore-patterns/design.md new file mode 100644 index 0000000..e49a6b1 --- /dev/null +++ b/specs/change/20260625-copy-gitignore-patterns/design.md @@ -0,0 +1,104 @@ +# Design + +## Research + +### Existing System + +- WTK parses config into `Config`, whose `copy` field is currently a `CopyConfig` table with separate optional `recursive` and `exact` lists. Source: `src/auxiliary.rs:12-28`. +- Effective config is loaded from legacy repo config, global `~/.wtk/config.toml`, and repo-local `.wtk/config.toml`; later files merge into earlier files. Source: `src/auxiliary.rs:688-695,764-780`. +- Current copy merge semantics override `recursive` and `exact` independently when each optional list is present. Source: `src/auxiliary.rs:113-120`. +- Current defaults are hardcoded as recursive file name `.env` and exact path `.agents`. Source: `src/worktree.rs:25-28`. +- Current recursive config only accepts terminal file names and rejects paths. Source: `src/worktree.rs:1586-1597`. +- Current exact config only accepts relative normal path components. Source: `src/worktree.rs:1599-1619`. +- Recursive matching currently uses `git ls-files --others --ignored --exclude-standard --full-name -z -- :(glob)**/` and filters by terminal file name. Source: `src/worktree.rs:1818-1855`. +- Exact matching currently uses `git ls-files --others --ignored --exclude-standard --full-name -z -- ` and keeps the exact path or descendants. Source: `src/worktree.rs:1857-1885`. +- Copy output currently splits matched paths back into recursive and exact buckets for reporting. Source: `src/worktree.rs:1898-1951`. +- Project documentation currently describes the split default lists and their output labels. Source: `docs/user-guide.md:51-59`. +- The installer/default config template currently writes the split `[copy]` table. Source: `scripts/default-config.toml:1-6`. + +### Design Inputs + +- User requirement: merge the two copy settings into one `copy = [...]` setting. +- User requirement: use gitignore syntax to reduce learning cost. +- User requirement: format the list across multiple lines for easier user edits. +- User constraint: direct change only; no compatibility with the old config shape is required. + +### Constraints & Dependencies + +- The config model, serializer, default template, documentation, and e2e tests all reference the old split shape. Source: `src/auxiliary.rs:18-28`, `scripts/default-config.toml:1-6`, `docs/user-guide.md:51-59`, `e2e/test_env_copy.py:61-63,212-214,348-359,403-404`. +- Existing behavior deliberately copies only ignored files/symlinks, including ignored descendants under `.agents/` while leaving tracked descendants alone. Source: `docs/user-guide.md:51`, `src/worktree.rs:1857-1885`. + +## Design Detail + +### Design Decisions + +- `copy = [...]` entries are Copy Patterns: gitignore-style patterns used to select ignored files or ignored directory descendants for copying. This removes the user-facing distinction between recursive file-name entries and exact path entries. Source: `src/auxiliary.rs:22-28`, `src/worktree.rs:1586-1619`. +- WTK must continue copying only files and symlinks that Git reports as ignored; tracked files remain outside Copy Pattern behavior even when they match a pattern. Source: `docs/user-guide.md:51`, `src/worktree.rs:1818-1885`. +- Copy Pattern matching should follow gitignore-style semantics rather than WTK-specific wildcard rules; invalid or unsafe copy intent such as empty entries, absolute paths, and parent-directory traversal must fail fast. Source: `src/worktree.rs:1586-1619`. +- The old `[copy] recursive/exact` config shape is intentionally removed without compatibility handling; old configs should fail to parse rather than silently migrate or continue. Source: `src/auxiliary.rs:22-28`, user decision. +- Runtime copy defaults are removed: missing `copy` means no ignored files are copied. The suggested `["**/.env", ".agents/"]` list belongs in the initialized/default config template, not in hidden code defaults. Source: `src/worktree.rs:25-28`, `scripts/default-config.toml:1-6`, user decision. +- Copy Patterns are read only from global `~/.wtk/config.toml`; repo-local config remains relevant for other WTK settings but does not provide Copy Patterns in this change. Source: `src/auxiliary.rs:688-784`, user decision. +- If repo-local `.wtk/config.toml` contains `copy`, WTK should fail fast with a clear error instead of ignoring it. Source: `src/auxiliary.rs:688-784`, user decision. +- Copy reporting should align with the unified Copy Pattern model and avoid printing one line per copied file when patterns or directories match many files. Source: `src/worktree.rs:1898-1951`, user decision. +- Copy reporting should use a compact total summary, such as `copied 12 ignored files`, and omit the line when no ignored files were copied. Source: `src/worktree.rs:1898-1951`, user decision. + +### Derived Rules + +- `**/.env` selects ignored `.env` files at any directory depth. +- `.agents/` selects ignored descendants under the Git-root-relative `.agents/` directory. +- Path patterns such as `apps/*/.env.local` are valid Copy Patterns. +- Copy Patterns are always interpreted relative to the source Repository Worktree's Git root. +- Documentation, default config, and tests should describe only `copy = [...]`. +- `copy = []` and missing `copy` both result in no Copy Patterns after config resolution. +- Error text for repo-local `copy` should explain that Copy Patterns are supported only in global `~/.wtk/config.toml`. +- Copy output should not expose old recursive/exact buckets or list every copied path. + +### System Structure + +- Change `Config.copy` from the current `CopyConfig` table to a top-level optional list of Copy Pattern strings. +- Keep auxiliary repositories and auxiliary groups in repo-local config loading, but resolve Copy Patterns from global config only. +- Add validation for Copy Pattern entries before any Git query runs. +- Replace `CopiedIgnoredFiles { recursive, exact }` with a unified structure that stores validated Copy Patterns. +- Use one snapshot path for all Copy Pattern matches, then dedupe by Git-root-relative path before copying. + +### System Procedure + +1. Load global `~/.wtk/config.toml` and extract `copy`, defaulting to `[]` when absent. +2. Load repo-local config for non-copy WTK settings; if it contains `copy`, stop with a clear error. +3. Validate each Copy Pattern as relative, non-empty, non-traversing gitignore-style copy intent. +4. Ask Git for ignored, untracked files and apply Copy Pattern matching relative to the source Repository Worktree root. +5. Snapshot matched files/symlinks, dedupe overlaps, copy them to the target Repository Worktree, and print one concise copied-count summary when count is greater than zero. + +### Change Scope + +Impact Areas: + +- Config model: replace split copy table with one global list. +- Config loading: separate copy resolution from repo-local settings and fail fast on repo-local copy. +- Copy matching: replace recursive/exact matching with unified Copy Pattern matching. +- Output: replace per-file recursive/exact reporting with count summary reporting. +- Tests and docs: update fixtures, expected output, and user-facing examples to the new config shape. + +Planned File Changes: + +- `src/auxiliary.rs`: update config structs, parsing, serialization, and global/repo-local copy validation. +- `src/worktree.rs`: replace copied ignored file rule structures, validation, matching, reporting, and default handling. +- `scripts/default-config.toml`: write multiline `copy = [...]` template. +- `docs/user-guide.md` and `README.md`: describe global-only Copy Patterns and concise output. +- `e2e/test_env_copy.py`: update copy behavior coverage for new config shape and output. +- `e2e/test_repo_mode.py` and `e2e/test_auxiliary_group.py`: update old copy fixture syntax where those tests still need copy behavior. + +### Edge Cases + +- Missing global config and `copy = []` both mean no ignored copy work. +- Pattern overlaps copy each matched file once. +- Directory patterns such as `.agents/` copy ignored descendants, not tracked descendants. +- Tracked files matching `**/.env` are left alone because Git does not report them as ignored untracked files. +- Repo-local `copy` fails before worktree mutation begins. +- Invalid Copy Patterns fail before copy work begins. + +### Verification Strategy + +- Use black-box e2e coverage for the acceptance path because the feature is user-visible config plus real Git ignored-file behavior. Source: `e2e/README.md:1-14`. +- Run `uv run --project e2e pytest e2e/test_env_copy.py` as the EAG for copy behavior. Source: `e2e/README.md:9-14`. +- Keep focused unit tests only where validation or pattern normalization has edge cases that are awkward to express through e2e setup. diff --git a/specs/change/20260625-copy-gitignore-patterns/spec.md b/specs/change/20260625-copy-gitignore-patterns/spec.md new file mode 100644 index 0000000..ca21a30 --- /dev/null +++ b/specs/change/20260625-copy-gitignore-patterns/spec.md @@ -0,0 +1,116 @@ +--- +id: 20260625-copy-gitignore-patterns +name: Copy Gitignore Patterns +status: implemented +created: '2026-06-25' +--- + +## Overview + +WTK should replace the current split ignored-file copy configuration with one user-facing copy list that uses gitignore-style patterns. + +### Problem Statement + +- The current `[copy].recursive` and `[copy].exact` settings expose implementation categories to users. +- Users should be able to express copy intent as a list of ignored path patterns without learning WTK-specific matching categories. + +### Goals + +- Use a single `copy = [...]` configuration list. +- Use gitignore-style pattern syntax so the learning cost matches existing Git ignore knowledge. +- Prefer multiline TOML lists so adding, removing, and reviewing entries is straightforward. +- Configure Copy Patterns only through global `~/.wtk/config.toml`. + +### Constraints + +- Do not preserve compatibility with the old `[copy] recursive/exact` shape. +- Keep copy behavior limited to files and symlinks that Git reports as ignored. +- Do not support repo-local Copy Pattern config in this change. + +### Success Criteria + +- The initialized global config template can be written as: + + ```toml + copy = [ + "**/.env", + ".agents/", + ] + ``` + +- Existing worktree creation flows copy ignored `.env` files and ignored `.agents/` descendants when that template config is present. +- When no `copy` config is present, WTK copies no ignored files by default. +- Copy output is concise and does not print one line for every copied file when directory patterns match many files. + +## Research + +See [design.md](./design.md). + +## Design + +### Design Summary + +Replace the current split copy table with a single ordered `copy = [...]` list of Copy Patterns. Patterns use gitignore-style syntax for user familiarity, but WTK still copies only files and symlinks that Git reports as ignored. + +See [design.md](./design.md) for design detail. + +### E2E Acceptance Gate (EAG) + +Acceptance behavior: With global `~/.wtk/config.toml` containing multiline `copy = ["**/.env", ".agents/"]`, worktree creation copies only Git-ignored matching files/symlinks, leaves tracked matches alone, rejects repo-local `copy`, and reports copied files with concise summary output. + +Verification path: `uv run --project e2e pytest e2e/test_env_copy.py` + +## Plan + +### Step 1 (AFK): Replace Copy Config Model + +Goal: Represent Copy Patterns as one global `copy = [...]` list with no runtime defaults. +Scope: Update config deserialization/serialization, global config resolution for copy, repo-local copy rejection, and default config template generation. +Depends on: None + +### Step 2 (AFK): Implement Gitignore-Style Copy Matching + +Goal: Copy ignored files and symlinks selected by Copy Patterns. +Scope: Replace recursive/exact matching with unified pattern matching, preserve ignored-only behavior, dedupe overlapping patterns, reject unsafe entries, and keep target paths Git-root-relative. +Depends on: Step 1 + +### Step 3 (AFK): Simplify Copy Reporting + +Goal: Align output with the unified Copy Pattern model. +Scope: Replace per-file recursive/exact reporting with concise copied-file count summaries and no copy line when zero files are copied. +Depends on: Step 2 + +### Step 4 (AFK): Update End-to-End Coverage + +Goal: Prove the new config shape and behavior through black-box tests. +Scope: Update existing copy e2e tests for global-only multiline `copy = [...]`, ignored-only directory behavior, no implicit runtime defaults, repo-local rejection, and concise output. +Depends on: Step 3 + +### Step 5 (AFK): EAG Validation + +Goal: Validate the completed change against the Spec's EAG before wrap-up work. +Scope: Run `uv run --project e2e pytest e2e/test_env_copy.py` and address failures. +Depends on: Step 4 + +### Step 6 (AFK): Documentation Sync + +Goal: Keep project documentation aligned with the implemented behavior. +Scope: If the implementation changes documented behavior, usage, commands, setup, or workflows, update the relevant project docs. +Depends on: Step 5 + +## Progress + +- [x] Step 1 (AFK): Replace Copy Config Model +- [x] Step 2 (AFK): Implement Gitignore-Style Copy Matching +- [x] Step 3 (AFK): Simplify Copy Reporting +- [x] Step 4 (AFK): Update End-to-End Coverage +- [x] Step 5 (AFK): EAG Validation +- [x] Step 6 (AFK): Documentation Sync + +## Implementation + +See [steps.md](./steps.md). + +## Deferred Follow-Ups (DFU) + +None. diff --git a/specs/change/20260625-copy-gitignore-patterns/steps.md b/specs/change/20260625-copy-gitignore-patterns/steps.md new file mode 100644 index 0000000..5401ce2 --- /dev/null +++ b/specs/change/20260625-copy-gitignore-patterns/steps.md @@ -0,0 +1,37 @@ +# Steps + +## Step 1 + +- changed: Replaced split `CopyConfig` table with top-level optional `copy = [...]`, removed runtime defaults, rejected repo-local copy in effective and repo-local config loading, and updated default config template. +- verified: `cargo check`; `uv run --project e2e pytest e2e/test_auxiliary_group.py` covers repo-local copy rejection in Auxiliary Group paths; e2e EAG after all steps. +- deviations-followups: None. + +## Step 2 + +- changed: Added unified Copy Pattern validation and globset-based matching over Git-reported ignored untracked files/symlinks, including overlap dedupe, pre-mutation glob compilation, and globbed directory pattern descendant handling. +- verified: `cargo test`; `uv run --project e2e pytest e2e/test_env_copy.py` covers invalid pattern fail-fast, globbed directories, and directory/file distinction. +- deviations-followups: None. + +## Step 3 + +- changed: Replaced recursive/exact per-file copy output with a concise copied-file count summary and no line for zero copied files. +- verified: `uv run --project e2e pytest e2e/test_env_copy.py e2e/test_repo_mode.py e2e/test_auxiliary_group.py e2e/test_pnpm.py`. +- deviations-followups: None. + +## Step 4 + +- changed: Updated e2e fixtures to global multiline copy lists and added coverage for no runtime defaults, repo-local rejection, concise output, ignored-only directory behavior, and overlap dedupe. +- verified: `uv run --project e2e pytest e2e/test_env_copy.py`; related e2e suite passed. +- deviations-followups: None. + +## Step 5 + +- changed: Ran and fixed the EAG until passing. +- verified: `uv run --project e2e pytest e2e/test_env_copy.py` passed. +- deviations-followups: None. + +## Step 6 + +- changed: Updated README, user guide, e2e README, CLI help, and installer/default-config fixtures for global-only Copy Patterns. +- verified: `scripts/test-install-local.sh && scripts/test-install.sh` passed. +- deviations-followups: None. diff --git a/src/auxiliary.rs b/src/auxiliary.rs index 00d0d70..e6b07d9 100644 --- a/src/auxiliary.rs +++ b/src/auxiliary.rs @@ -15,16 +15,8 @@ pub struct Config { pub auxiliaries: BTreeMap, #[serde(default, rename = "auxiliary-groups", alias = "groups")] pub groups: BTreeMap, - #[serde(default, skip_serializing_if = "CopyConfig::is_empty")] - pub copy: CopyConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CopyConfig { - #[serde(default)] - pub recursive: Option>, - #[serde(default)] - pub exact: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -102,26 +94,13 @@ impl Config { fn merge_from(&mut self, other: Config) { self.auxiliaries.extend(other.auxiliaries); self.groups.extend(other.groups); - self.copy.merge_from(other.copy); - } - - fn is_empty(&self) -> bool { - self.auxiliaries.is_empty() && self.groups.is_empty() && self.copy.is_empty() - } -} - -impl CopyConfig { - fn merge_from(&mut self, other: CopyConfig) { - if other.recursive.is_some() { - self.recursive = other.recursive; - } - if other.exact.is_some() { - self.exact = other.exact; + if other.copy.is_some() { + self.copy = other.copy; } } fn is_empty(&self) -> bool { - self.recursive.is_none() && self.exact.is_none() + self.auxiliaries.is_empty() && self.groups.is_empty() && self.copy.is_none() } } @@ -689,7 +668,9 @@ pub fn load_effective_config(primary_root: &Path, git_common_dir: &Path) -> AppR let mut config = Config::default(); for path in config_paths_in_precedence_order(primary_root, git_common_dir) { if path.exists() { - config.merge_from(read_config(&path)?); + let next = read_config(&path)?; + reject_repo_local_copy_config(&path, primary_root, git_common_dir, &next)?; + config.merge_from(next); } } Ok(config) @@ -750,17 +731,46 @@ fn primary_config_path(primary_root: &Path) -> PathBuf { fn load_repo_config(primary_root: &Path, git_common_dir: &Path) -> AppResult { let primary = primary_config_path(primary_root); if primary.exists() { - return read_config(&primary); + return read_repo_local_config(&primary, primary_root, git_common_dir); } let legacy = legacy_config_path(git_common_dir); if legacy.exists() { - return read_config(&legacy); + return read_repo_local_config(&legacy, primary_root, git_common_dir); } Ok(Config::default()) } +fn read_repo_local_config( + path: &Path, + primary_root: &Path, + git_common_dir: &Path, +) -> AppResult { + let config = read_config(path)?; + reject_repo_local_copy_config(path, primary_root, git_common_dir, &config)?; + Ok(config) +} + +fn reject_repo_local_copy_config( + path: &Path, + primary_root: &Path, + git_common_dir: &Path, + config: &Config, +) -> AppResult<()> { + if is_repo_local_config_path(path, primary_root, git_common_dir) && config.copy.is_some() { + return Err(Error::message(format!( + "Copy Patterns are supported only in global ~/.wtk/config.toml; remove copy from repo-local config {}", + path.display() + ))); + } + Ok(()) +} + +fn is_repo_local_config_path(path: &Path, primary_root: &Path, git_common_dir: &Path) -> bool { + path == primary_config_path(primary_root) || path == legacy_config_path(git_common_dir) +} + fn config_paths_in_precedence_order(primary_root: &Path, git_common_dir: &Path) -> Vec { let mut paths = Vec::new(); let legacy = legacy_config_path(git_common_dir); diff --git a/src/cli.rs b/src/cli.rs index 0d0cec3..0db6bb1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -783,7 +783,7 @@ fn command_help(command: &str) -> &'static str { "init-worktree" => concat!( "Usage: wtk init-worktree [flags]\n\n", "Advanced command:\n", - " Copy configured ignored recursive files from source-root into worktree-path and run pnpm install when needed.\n\n", + " Copy configured ignored files from source-root into worktree-path and run pnpm install when needed.\n\n", "Flags:\n", " -h, --help\n", ), diff --git a/src/worktree.rs b/src/worktree.rs index 781b6da..2ce7d49 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -5,6 +5,7 @@ use crate::list::{self, AuxiliaryRefDetail, AuxiliaryRefSummary, ListOptions}; use crate::output; use crate::paths::default_path; use crate::{AppResult, Error}; +use globset::{Glob, GlobSet, GlobSetBuilder}; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; @@ -24,19 +25,12 @@ use std::time::{SystemTime, UNIX_EPOCH}; const RECURSIVE_IGNORED_FILE_SNAPSHOT_PREFIX: &str = "wtk-init-worktree-snapshot-"; const RECURSIVE_IGNORED_FILE_SNAPSHOT_MARKER: &str = ".wtk-recursive-ignored-files-snapshot"; -const DEFAULT_RECURSIVE_IGNORED_FILE_NAMES: &[&str] = &[".env"]; -const DEFAULT_EXACT_IGNORED_FILE_PATHS: &[&str] = &[".agents"]; #[derive(Debug, Clone)] struct CopiedIgnoredFiles { - recursive: Vec, - exact: Vec, -} - -#[derive(Debug, Clone)] -struct RecursiveIgnoredFileSpec { - file_name: String, - copy_label: String, + patterns: Vec, + matcher: CopyPatternMatcher, + pathspecs: Vec, } #[derive(Debug, Default, Clone)] @@ -121,8 +115,7 @@ pub(crate) enum SnapshotFileKind { } pub struct SendOutWorktreeInit { - recursive_ignored_files: Vec, - exact_ignored_files: Vec, + ignored_files: Vec, } pub enum AsyncPnpmInstall { @@ -154,26 +147,14 @@ pub fn create(session: &mut Session<'_>, opts: Options) -> AppResult<()> { session .git .run(&repo.main_root, args.iter().map(String::as_str))?; - let recursive_ignored_files = - snapshot_recursive_ignored_files(session, &repo.main_root, &copied_files.recursive) - .map_err(|error| { - Error::message(format!( - "worktree created, but failed to snapshot ignored recursive files: {error}" - )) - })?; - let exact_ignored_files = - snapshot_exact_ignored_files(session, &repo.main_root, &copied_files.exact).map_err( - |error| { - Error::message(format!( - "worktree created, but failed to snapshot ignored exact files: {error}" - )) - }, - )?; - let ignored_snapshot_root = write_recursive_ignored_file_snapshot( - &recursive_ignored_files, - &exact_ignored_files, - &path, - )?; + let ignored_files = snapshot_copy_pattern_files(session, &repo.main_root, &copied_files) + .map_err(|error| { + Error::message(format!( + "worktree created, but failed to snapshot ignored files: {error}" + )) + })?; + print_copied_file_count(session, ignored_files.len())?; + let ignored_snapshot_root = write_recursive_ignored_file_snapshot(&ignored_files, &path)?; cleanup_recursive_ignored_file_snapshot_on_error( finish( session, @@ -307,12 +288,8 @@ fn create_with_auxiliaries(session: &mut Session<'_>, opts: Options) -> AppResul } let copied_files = copied_ignored_files(session, &repo.main_root)?; - let primary_ignored = - snapshot_recursive_ignored_files(session, &repo.main_root, &copied_files.recursive)?; - let primary_exact = - snapshot_exact_ignored_files(session, &repo.main_root, &copied_files.exact)?; let ignored_files_to_copy = - dedupe_snapshot_files(merge_snapshot_files(&primary_ignored, &primary_exact)); + snapshot_copy_pattern_files(session, &repo.main_root, &copied_files)?; print_copied_files( session, &copied_files, @@ -390,13 +367,8 @@ pub fn checkout(session: &mut Session<'_>, opts: Options) -> AppResult<()> { let path = create_target_path(&repo, &opts.branch, &opts.path)?; let copied_files = copied_ignored_files(session, &repo.main_root)?; - let ignored_files = - snapshot_recursive_ignored_files(session, &repo.main_root, &copied_files.recursive)?; - let exact_ignored_files = - snapshot_exact_ignored_files(session, &repo.main_root, &copied_files.exact)?; - let mut ignored_files_to_copy = ignored_files.clone(); - ignored_files_to_copy.extend_from_slice(&exact_ignored_files); - let ignored_files_to_copy = dedupe_snapshot_files(ignored_files_to_copy); + let ignored_files_to_copy = + snapshot_copy_pattern_files(session, &repo.main_root, &copied_files)?; let args = vec![ "worktree".to_string(), "add".to_string(), @@ -539,12 +511,7 @@ fn worktree_init_without_pnpm( worktree_path: &Path, ) -> AppResult<()> { let copied_files = copied_ignored_files(session, source_root)?; - let ignored_files = - snapshot_recursive_ignored_files(session, source_root, &copied_files.recursive)?; - let exact_ignored_files = - snapshot_exact_ignored_files(session, source_root, &copied_files.exact)?; - let ignored_files_to_copy = - dedupe_snapshot_files(merge_snapshot_files(&ignored_files, &exact_ignored_files)); + let ignored_files_to_copy = snapshot_copy_pattern_files(session, source_root, &copied_files)?; print_copied_files( session, &copied_files, @@ -759,19 +726,7 @@ fn copy_recursive_ignored_files_for_init( } }, None => ( - { - let mut ignored = snapshot_recursive_ignored_files( - session, - source_root, - &copied_files.recursive, - )?; - ignored.extend(snapshot_exact_ignored_files( - session, - source_root, - &copied_files.exact, - )?); - dedupe_snapshot_files(ignored) - }, + { snapshot_copy_pattern_files(session, source_root, &copied_files)? }, None, ), }; @@ -913,10 +868,7 @@ pub fn send_out(session: &mut Session<'_>, opts: Options) -> AppResult<()> { ensure_creatable_parent(&path)?; let copied_files = copied_ignored_files(session, &repo.main_root)?; - let ignored_files = - snapshot_recursive_ignored_files(session, &repo.main_root, &copied_files.recursive)?; - let exact_ignored_files = - snapshot_exact_ignored_files(session, &repo.main_root, &copied_files.exact)?; + let ignored_files = snapshot_copy_pattern_files(session, &repo.main_root, &copied_files)?; let switch_args = vec!["switch".to_string(), base.clone()]; output::git(session.out, &repo.main_root, &switch_args)?; @@ -943,12 +895,10 @@ pub fn send_out(session: &mut Session<'_>, opts: Options) -> AppResult<()> { error ))); } - let ignored_files_to_copy = - dedupe_snapshot_files(merge_snapshot_files(&ignored_files, &exact_ignored_files)); print_copied_files( session, &copied_files, - copy_snapshot_files(&ignored_files_to_copy, &path).map_err(|error| { + copy_snapshot_files(&ignored_files, &path).map_err(|error| { Error::message(format!( "main worktree switched to {base} and linked worktree created, but ignored file copy failed: {error}" )) @@ -1548,100 +1498,58 @@ fn copy_snapshot_files( fn copied_ignored_files(session: &Session<'_>, main_root: &Path) -> AppResult { let repo = resolve(&session.git, main_root)?; let config = auxiliary::load_effective_config(&repo.main_root, &repo.git_common_dir)?; - let recursive = match config.copy.recursive { - Some(file_names) => file_names - .into_iter() - .map(|file_name| { - validate_recursive_ignored_file_name(&file_name)?; - Ok(RecursiveIgnoredFileSpec { - copy_label: format!("copied ignored {file_name}"), - file_name, - }) - }) - .collect::>>()?, - None => DEFAULT_RECURSIVE_IGNORED_FILE_NAMES - .iter() - .map(|file_name| RecursiveIgnoredFileSpec { - copy_label: format!("copied ignored {file_name}"), - file_name: (*file_name).to_string(), - }) - .collect(), - }; - let exact = match config.copy.exact { - Some(paths) => paths - .into_iter() - .map(|path| { - validate_exact_ignored_file_path(&path)?; - Ok(path) - }) - .collect::>>()?, - None => DEFAULT_EXACT_IGNORED_FILE_PATHS - .iter() - .map(PathBuf::from) - .collect(), - }; - Ok(CopiedIgnoredFiles { recursive, exact }) + let patterns = config.copy.unwrap_or_default(); + for pattern in &patterns { + validate_copy_pattern(pattern)?; + } + let matcher = CopyPatternMatcher::new(&patterns)?; + let pathspecs = copy_pattern_pathspecs(&patterns); + Ok(CopiedIgnoredFiles { + patterns, + matcher, + pathspecs, + }) } -fn validate_recursive_ignored_file_name(file_name: &str) -> AppResult<()> { - if file_name.is_empty() { - return Err(Error::message("copy.recursive entries must not be empty")); +fn validate_copy_pattern(pattern: &str) -> AppResult<()> { + if pattern.is_empty() { + return Err(Error::message("copy entries must not be empty")); } - let path = Path::new(file_name); - match path.components().next() { - Some(Component::Normal(_)) if path.components().count() == 1 => Ok(()), - _ => Err(Error::message(format!( - "copy.recursive entries must be file names, not paths: {file_name}" - ))), - } -} - -fn validate_exact_ignored_file_path(path: &Path) -> AppResult<()> { - if path.as_os_str().is_empty() { - return Err(Error::message("copy.exact entries must not be empty")); + if pattern.starts_with('!') { + return Err(Error::message(format!( + "copy entries do not support negation patterns: {pattern}" + ))); } - if path.is_absolute() { + if pattern.starts_with('/') || Path::new(pattern).is_absolute() { return Err(Error::message(format!( - "copy.exact entries must be relative paths: {}", - path.display() + "copy entries must be relative patterns: {pattern}" ))); } - if path - .components() - .any(|component| !matches!(component, Component::Normal(_))) - { + if Path::new(pattern).components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { return Err(Error::message(format!( - "copy.exact entries must contain only normal path components: {}", - path.display() + "copy entries must not traverse outside the repository: {pattern}" ))); } Ok(()) } -fn snapshot_recursive_ignored_files( +fn snapshot_copy_pattern_files( session: &Session<'_>, main_root: &Path, - specs: &[RecursiveIgnoredFileSpec], + copied_files: &CopiedIgnoredFiles, ) -> AppResult> { let mut ignored = Vec::new(); - for relative in recursive_ignored_files(session, main_root, specs)? { + for relative in copy_pattern_ignored_files(session, main_root, copied_files)? { if let Some(snapshot) = snapshot_file(main_root, relative)? { ignored.push(snapshot); } } - Ok(ignored) -} - -fn snapshot_exact_ignored_files( - session: &Session<'_>, - main_root: &Path, - exact_paths: &[PathBuf], -) -> AppResult> { - let mut ignored = Vec::new(); - for relative in exact_paths { - ignored.extend(snapshot_ignored_exact_path(session, main_root, relative)?); - } - Ok(ignored) + Ok(dedupe_snapshot_files(ignored)) } pub fn snapshot_send_out_worktree_init( @@ -1650,12 +1558,7 @@ pub fn snapshot_send_out_worktree_init( ) -> AppResult { let copied_files = copied_ignored_files(session, main_root)?; Ok(SendOutWorktreeInit { - recursive_ignored_files: snapshot_recursive_ignored_files( - session, - main_root, - &copied_files.recursive, - )?, - exact_ignored_files: snapshot_exact_ignored_files(session, main_root, &copied_files.exact)?, + ignored_files: snapshot_copy_pattern_files(session, main_root, &copied_files)?, }) } @@ -1665,42 +1568,15 @@ pub fn apply_send_out_worktree_init( init: &SendOutWorktreeInit, ) -> AppResult<()> { let copied_files = copied_ignored_files(session, worktree_path)?; - let ignored_files_to_copy = dedupe_snapshot_files(merge_snapshot_files( - &init.recursive_ignored_files, - &init.exact_ignored_files, - )); print_copied_files( session, &copied_files, - copy_snapshot_files(&ignored_files_to_copy, worktree_path) + copy_snapshot_files(&init.ignored_files, worktree_path) .map_err(|error| Error::message(format!("ignored file copy failed: {error}")))?, )?; Ok(()) } -fn merge_snapshot_files( - recursive_ignored_files: &[SnapshotFile], - exact_ignored_files: &[SnapshotFile], -) -> Vec { - let mut ignored_files = recursive_ignored_files.to_vec(); - ignored_files.extend_from_slice(exact_ignored_files); - ignored_files -} - -fn snapshot_ignored_exact_path( - session: &Session<'_>, - main_root: &Path, - relative: &Path, -) -> AppResult> { - let mut ignored = Vec::new(); - for path in ignored_exact_paths(session, main_root, relative)? { - if let Some(snapshot) = snapshot_file(main_root, path)? { - ignored.push(snapshot); - } - } - Ok(ignored) -} - fn snapshot_recursive_ignored_files_from_root(root: &Path) -> AppResult> { let mut ignored = Vec::new(); collect_recursive_ignored_files_from_root(root, root, &mut ignored)?; @@ -1815,12 +1691,12 @@ fn create_symlink(target: &Path, path: &Path) -> std::io::Result<()> { windows_fs::symlink_file(target, path) } -fn recursive_ignored_files( +fn copy_pattern_ignored_files( session: &Session<'_>, main_root: &Path, - specs: &[RecursiveIgnoredFileSpec], + copied_files: &CopiedIgnoredFiles, ) -> AppResult> { - if specs.is_empty() { + if copied_files.patterns.is_empty() { return Ok(Vec::new()); } let mut args = vec![ @@ -1832,57 +1708,130 @@ fn recursive_ignored_files( "-z".to_string(), "--".to_string(), ]; - for spec in specs { - args.push(spec.file_name.to_string()); - args.push(format!(":(glob)**/{}", spec.file_name)); - } - let output = session - .git - .run_bytes(main_root, args.iter().map(String::as_str))?; + args.extend(copied_files.pathspecs.iter().cloned()); + let output = session.git.run_bytes(main_root, args)?; let mut ignored: Vec<_> = output .stdout .split(|byte| *byte == b'\0') .filter(|path| !path.is_empty()) .map(path_buf_from_git_bytes) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|file_name| specs.iter().any(|spec| spec.file_name == file_name)) - }) + .filter(|path| copied_files.matcher.is_match(path)) .collect(); ignored.sort(); + ignored.dedup(); Ok(ignored) } -fn ignored_exact_paths( - session: &Session<'_>, - main_root: &Path, - relative: &Path, -) -> AppResult> { - let relative = relative.to_string_lossy().into_owned(); - let output = session.git.run_bytes( - main_root, - [ - "ls-files", - "--others", - "--ignored", - "--exclude-standard", - "--full-name", - "-z", - "--", - &relative, - ], - )?; - let mut ignored: Vec<_> = output - .stdout - .split(|byte| *byte == b'\0') - .filter(|path| !path.is_empty()) - .map(path_buf_from_git_bytes) - .filter(|path| path == Path::new(&relative) || path.starts_with(Path::new(&relative))) - .collect(); - ignored.sort(); - ignored.dedup(); - Ok(ignored) +#[derive(Debug, Clone)] +struct CopyPatternMatcher { + globset: GlobSet, + basename_globset: GlobSet, + directory_globset: GlobSet, + descendant_globset: GlobSet, +} + +impl CopyPatternMatcher { + fn new(patterns: &[String]) -> AppResult { + let mut glob_builder = GlobSetBuilder::new(); + let mut basename_builder = GlobSetBuilder::new(); + let mut directory_builder = GlobSetBuilder::new(); + let mut descendant_builder = GlobSetBuilder::new(); + for pattern in patterns { + if pattern.ends_with('/') { + for directory_pattern in copy_pattern_directory_patterns(pattern) { + directory_builder.add(Glob::new(&directory_pattern).map_err(|error| { + Error::message(format!("invalid copy pattern {pattern:?}: {error}")) + })?); + } + continue; + } + glob_builder.add(Glob::new(pattern).map_err(|error| { + Error::message(format!("invalid copy pattern {pattern:?}: {error}")) + })?); + if let Some(root_pattern) = pattern.strip_prefix("**/") { + glob_builder.add(Glob::new(root_pattern).map_err(|error| { + Error::message(format!("invalid copy pattern {pattern:?}: {error}")) + })?); + } + if !pattern.contains('/') { + basename_builder.add(Glob::new(pattern).map_err(|error| { + Error::message(format!("invalid copy pattern {pattern:?}: {error}")) + })?); + } + for directory_pattern in copy_pattern_descendant_patterns(pattern) { + descendant_builder.add(Glob::new(&directory_pattern).map_err(|error| { + Error::message(format!("invalid copy pattern {pattern:?}: {error}")) + })?); + } + } + Ok(Self { + globset: glob_builder.build().map_err(|error| { + Error::message(format!("failed to build copy pattern matcher: {error}")) + })?, + basename_globset: basename_builder.build().map_err(|error| { + Error::message(format!("failed to build copy pattern matcher: {error}")) + })?, + directory_globset: directory_builder.build().map_err(|error| { + Error::message(format!("failed to build copy pattern matcher: {error}")) + })?, + descendant_globset: descendant_builder.build().map_err(|error| { + Error::message(format!("failed to build copy pattern matcher: {error}")) + })?, + }) + } + + fn is_match(&self, path: &Path) -> bool { + self.globset.is_match(path) + || path + .file_name() + .is_some_and(|name| self.basename_globset.is_match(Path::new(name))) + || self.directory_globset.is_match(path) + || self.descendant_globset.is_match(path) + } +} + +fn copy_pattern_pathspecs(patterns: &[String]) -> Vec { + let mut pathspecs = Vec::new(); + for pattern in patterns { + if pattern.ends_with('/') { + for directory_pattern in copy_pattern_directory_patterns(pattern) { + pathspecs.push(git_glob_pathspec(&directory_pattern)); + } + continue; + } + pathspecs.push(git_glob_pathspec(pattern)); + if let Some(root_pattern) = pattern.strip_prefix("**/") { + pathspecs.push(git_glob_pathspec(root_pattern)); + } + if !pattern.contains('/') { + pathspecs.push(git_glob_pathspec(&format!("**/{pattern}"))); + } + for directory_pattern in copy_pattern_descendant_patterns(pattern) { + pathspecs.push(git_glob_pathspec(&directory_pattern)); + } + } + pathspecs.sort(); + pathspecs.dedup(); + pathspecs +} + +fn git_glob_pathspec(pattern: &str) -> String { + format!(":(glob){pattern}") +} + +fn copy_pattern_directory_patterns(pattern: &str) -> Vec { + let trimmed = pattern.trim_end_matches('/'); + copy_pattern_descendant_patterns(trimmed) +} + +fn copy_pattern_descendant_patterns(pattern: &str) -> Vec { + let mut patterns = vec![format!("{pattern}/**")]; + if !pattern.contains('/') { + patterns.push(format!("**/{pattern}/**")); + } else if let Some(root_pattern) = pattern.strip_prefix("**/") { + patterns.push(format!("{root_pattern}/**")); + } + patterns } #[cfg(unix)] @@ -1895,69 +1844,20 @@ fn path_buf_from_git_bytes(path: &[u8]) -> PathBuf { PathBuf::from(String::from_utf8_lossy(path).into_owned()) } -fn print_copied_recursive_ignored_files( - session: &mut Session<'_>, - specs: &[RecursiveIgnoredFileSpec], - copied: Vec, -) -> AppResult<()> { - for relative in copied { - let file_name = relative - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| { - Error::message(format!( - "ignored file has no terminal file name: {}", - relative.display() - )) - })?; - let spec = specs - .iter() - .find(|spec| spec.file_name == file_name) - .ok_or_else(|| { - Error::message(format!( - "ignored file is not configured for copy reporting: {}", - relative.display() - )) - })?; - writeln!(session.out, "{}: {}", spec.copy_label, relative.display())?; - } - Ok(()) -} - fn print_copied_files( session: &mut Session<'_>, - copied_files: &CopiedIgnoredFiles, + _copied_files: &CopiedIgnoredFiles, copied: Vec, ) -> AppResult<()> { - let mut recursive = Vec::new(); - let mut exact = Vec::new(); - for relative in copied { - let is_recursive = relative - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|file_name| { - copied_files - .recursive - .iter() - .any(|spec| spec.file_name == file_name) - }); - if is_recursive { - recursive.push(relative); - } else { - exact.push(relative); - } + if !copied.is_empty() { + print_copied_file_count(session, copied.len())?; } - print_copied_recursive_ignored_files(session, &copied_files.recursive, recursive)?; - print_copied_ignored_files(session, "copied ignored file", exact) + Ok(()) } -fn print_copied_ignored_files( - session: &mut Session<'_>, - label: &str, - copied: Vec, -) -> AppResult<()> { - for relative in copied { - writeln!(session.out, "{label}: {}", relative.display())?; +fn print_copied_file_count(session: &mut Session<'_>, count: usize) -> AppResult<()> { + if count > 0 { + writeln!(session.out, "copied {count} ignored files")?; } Ok(()) } @@ -2007,8 +1907,7 @@ fn start_async_init_worktree( } fn write_recursive_ignored_file_snapshot( - recursive_ignored_files: &[SnapshotFile], - exact_ignored_files: &[SnapshotFile], + ignored_files: &[SnapshotFile], worktree_path: &Path, ) -> AppResult { let nonce = SystemTime::now() @@ -2033,12 +1932,8 @@ fn write_recursive_ignored_file_snapshot( )) })?; write_recursive_ignored_file_snapshot_marker(&snapshot_root)?; - let ignored_files = dedupe_snapshot_files(merge_snapshot_files( - recursive_ignored_files, - exact_ignored_files, - )); cleanup_recursive_ignored_file_snapshot_on_error( - copy_snapshot_files(&ignored_files, &snapshot_root) + copy_snapshot_files(ignored_files, &snapshot_root) .map_err(|error| { Error::message(format!( "worktree created, but failed to snapshot ignored files in {}: {error}", @@ -2515,14 +2410,87 @@ mod tests { } #[test] - fn recursive_ignored_files_short_circuits_when_specs_are_empty() { + fn copy_pattern_ignored_files_short_circuits_when_patterns_are_empty() { let mut out = io::sink(); let mut clipboard = FailingClipboard; let session = super::Session::new(PathBuf::from("."), &mut out, &mut clipboard, false); - let ignored = super::recursive_ignored_files(&session, Path::new("missing-directory"), &[]) - .expect("empty recursive specs should not invoke git"); + let copied_files = super::CopiedIgnoredFiles { + patterns: Vec::new(), + matcher: super::CopyPatternMatcher::new(&[]).unwrap(), + pathspecs: Vec::new(), + }; + let ignored = super::copy_pattern_ignored_files( + &session, + Path::new("missing-directory"), + &copied_files, + ) + .expect("empty copy patterns should not invoke git"); assert!(ignored.is_empty()); } + + #[test] + fn copy_pattern_matcher_treats_slashless_patterns_as_directory_basenames() { + let matcher = super::CopyPatternMatcher::new(&["secrets".to_string()]).unwrap(); + + assert!(matcher.is_match(Path::new("secrets"))); + assert!(matcher.is_match(Path::new("config/secrets"))); + assert!(matcher.is_match(Path::new("secrets/token"))); + assert!(matcher.is_match(Path::new("config/secrets/token"))); + assert!(!matcher.is_match(Path::new("config/secret/token"))); + } + + #[test] + fn copy_pattern_matcher_preserves_descendants_for_slash_patterns_without_trailing_slash() { + let matcher = super::CopyPatternMatcher::new(&["specs/change/active".to_string()]).unwrap(); + + assert!(matcher.is_match(Path::new("specs/change/active"))); + assert!(matcher.is_match(Path::new("specs/change/active/plan.md"))); + assert!(!matcher.is_match(Path::new("specs/change/inactive/plan.md"))); + } + + #[test] + fn copy_pattern_matcher_treats_trailing_slash_patterns_as_nested_directories() { + let matcher = super::CopyPatternMatcher::new(&[".agents/".to_string()]).unwrap(); + + assert!(matcher.is_match(Path::new(".agents/instructions.md"))); + assert!(matcher.is_match(Path::new("nested/.agents/instructions.md"))); + assert!(!matcher.is_match(Path::new("nested/agents/instructions.md"))); + } + + #[test] + fn copy_pattern_pathspecs_include_descendants_for_slashless_and_directory_patterns() { + let pathspecs = super::copy_pattern_pathspecs(&[ + "secrets".to_string(), + ".agents/".to_string(), + "specs/change/active".to_string(), + "**/.env".to_string(), + ]); + + assert!(pathspecs.contains(&":(glob)secrets".to_string())); + assert!(pathspecs.contains(&":(glob)**/secrets".to_string())); + assert!(pathspecs.contains(&":(glob)secrets/**".to_string())); + assert!(pathspecs.contains(&":(glob)**/secrets/**".to_string())); + assert!(pathspecs.contains(&":(glob).agents/**".to_string())); + assert!(pathspecs.contains(&":(glob)**/.agents/**".to_string())); + assert!(pathspecs.contains(&":(glob)specs/change/active".to_string())); + assert!(pathspecs.contains(&":(glob)specs/change/active/**".to_string())); + assert!(pathspecs.contains(&":(glob)**/.env".to_string())); + assert!(pathspecs.contains(&":(glob).env".to_string())); + assert!(pathspecs.contains(&":(glob)**/.env/**".to_string())); + assert!(pathspecs.contains(&":(glob).env/**".to_string())); + } + + #[test] + fn validate_copy_pattern_rejects_negation() { + let error = super::validate_copy_pattern("!secrets/public/**") + .expect_err("negated copy patterns should fail fast"); + + assert!( + error + .to_string() + .contains("copy entries do not support negation patterns") + ); + } }