skillflow-py 1.3.2__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/PKG-INFO +1 -1
  2. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/pyproject.toml +1 -1
  3. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/read_tools.py +31 -8
  4. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/repo_apply/impl.py +17 -26
  5. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/repo_apply/tool.yaml +4 -0
  6. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow_py.egg-info/PKG-INFO +1 -1
  7. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow_py.egg-info/SOURCES.txt +1 -0
  8. skillflow_py-1.4.0/tests/test_noop_floor_fix.py +65 -0
  9. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_tools.py +61 -1
  10. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/LICENSE +0 -0
  11. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/README.md +0 -0
  12. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/setup.cfg +0 -0
  13. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/__init__.py +0 -0
  14. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/agent_registry.py +0 -0
  15. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/context.py +0 -0
  16. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/convert_cli.py +0 -0
  17. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/core.py +0 -0
  18. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/exceptions.py +0 -0
  19. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/graph.py +0 -0
  20. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/notifications.py +0 -0
  21. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/outbox.py +0 -0
  22. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/linter/__init__.py +0 -0
  23. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/linter/cli.py +0 -0
  24. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
  25. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
  26. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
  27. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
  28. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_converter/converter.py +0 -0
  29. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
  30. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
  31. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
  32. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
  33. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
  34. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
  35. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
  36. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/plugins/skill_runner/runner.py +0 -0
  37. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/recovery.py +0 -0
  38. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/run_cli.py +0 -0
  39. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/schema.py +0 -0
  40. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/step_validation.py +0 -0
  41. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tool_loader.py +0 -0
  42. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/__init__.py +0 -0
  43. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/dir_tree/impl.py +0 -0
  44. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
  45. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/draft_commit/impl.py +0 -0
  46. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
  47. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/file_exists/impl.py +0 -0
  48. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/file_exists/tool.yaml +0 -0
  49. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
  50. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
  51. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/json_schema/impl.py +0 -0
  52. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/json_schema/tool.yaml +0 -0
  53. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/lint/impl.py +0 -0
  54. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/lint/tool.yaml +0 -0
  55. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/list_tree/impl.py +0 -0
  56. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/list_tree/tool.yaml +0 -0
  57. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/notify/impl.py +0 -0
  58. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/notify/tool.yaml +0 -0
  59. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/pytest/impl.py +0 -0
  60. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/pytest/tool.yaml +0 -0
  61. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/read_file/impl.py +0 -0
  62. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/read_file/tool.yaml +0 -0
  63. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/repo_validate/impl.py +0 -0
  64. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
  65. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/write/impl.py +0 -0
  66. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/tools/write/tool.yaml +0 -0
  67. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/validation.py +0 -0
  68. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/workspace.py +0 -0
  69. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow/write_tools.py +0 -0
  70. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
  71. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow_py.egg-info/entry_points.txt +0 -0
  72. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow_py.egg-info/requires.txt +0 -0
  73. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/src/skillflow_py.egg-info/top_level.txt +0 -0
  74. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_context.py +0 -0
  75. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_core.py +0 -0
  76. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_exceptions.py +0 -0
  77. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_existing_repo_apply.py +0 -0
  78. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_git_sync_pre.py +0 -0
  79. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_graph.py +0 -0
  80. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_integration.py +0 -0
  81. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_integration_configs.py +0 -0
  82. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_migration_regression.py +0 -0
  83. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_notifications.py +0 -0
  84. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_outbox.py +0 -0
  85. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_read_tools_exec.py +0 -0
  86. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_read_tools_label.py +0 -0
  87. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_recovery.py +0 -0
  88. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_regression.py +0 -0
  89. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_step_validation.py +0 -0
  90. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_tool_loader.py +0 -0
  91. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_tool_lock_contention.py +0 -0
  92. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_validation.py +0 -0
  93. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_workspace.py +0 -0
  94. {skillflow_py-1.3.2 → skillflow_py-1.4.0}/tests/test_write_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.3.2
3
+ Version: 1.4.0
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skillflow-py"
7
- version = "1.3.2"
7
+ version = "1.4.0"
8
8
  description = "Config-agnostic LLM pipeline graph executor — turn skills into flows"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -27,6 +27,21 @@ from pathlib import Path
27
27
  # the ``truncated`` flag signals when there is more to page through.
28
28
  _MAX_READ_LINES = 2000
29
29
 
30
+ # Directories never worth enumerating/searching as "source" — VCS internals and
31
+ # build/dependency caches. Mirrors dir_tree/list_tree's BLOCKED set, which the
32
+ # generated directory read tools previously lacked: a single list_repo_root on a
33
+ # real repo dumped the whole .git tree (~64% of files) into the agent's context.
34
+ _BLOCKED_DIR_PARTS = {".git", "__pycache__", ".venv", "node_modules",
35
+ ".mypy_cache", ".pytest_cache", ".ruff_cache"}
36
+ # Hard cap on entries returned by a directory listing so one call can't flood
37
+ # the context with thousands of paths.
38
+ _MAX_LIST_ENTRIES = 1000
39
+
40
+
41
+ def _is_blocked_path(rel) -> bool:
42
+ """True if any path component is a blocked VCS/build directory."""
43
+ return any(part in _BLOCKED_DIR_PARTS for part in Path(rel).parts)
44
+
30
45
 
31
46
  def _page_lines(text: str, start_line: int = 0, end_line: int | None = None) -> dict:
32
47
  """Slice file text into a line-numbered window with paging metadata.
@@ -407,19 +422,25 @@ def make_read_tool_fns(specs: list[dict], workspace_root: str,
407
422
 
408
423
  def _list_dir(_paths=dir_paths) -> str:
409
424
  entries = []
425
+ truncated = False
410
426
  for dp in _paths:
411
427
  d = Path(dp)
412
428
  if not d.is_dir():
413
429
  continue
414
430
  for f in sorted(d.rglob("*")):
415
- if f.is_file() and f.name != ".gitkeep":
416
- rel = f.relative_to(d)
417
- st = f.stat()
418
- entries.append({
419
- "name": str(rel),
420
- "size": st.st_size,
421
- })
422
- return json.dumps(entries, ensure_ascii=False)
431
+ if not (f.is_file() and f.name != ".gitkeep"):
432
+ continue
433
+ rel = f.relative_to(d)
434
+ if _is_blocked_path(rel):
435
+ continue # skip .git / build / dependency caches
436
+ entries.append({"name": str(rel), "size": f.stat().st_size})
437
+ if len(entries) >= _MAX_LIST_ENTRIES:
438
+ truncated = True
439
+ break
440
+ if truncated:
441
+ break
442
+ return json.dumps({"files": entries, "truncated": truncated},
443
+ ensure_ascii=False)
423
444
 
424
445
  def _read_dir_file(name: str, start_line: int = 0,
425
446
  end_line: int | None = None, _paths=dir_paths) -> dict:
@@ -465,6 +486,8 @@ def make_read_tool_fns(specs: list[dict], workspace_root: str,
465
486
  for f in sorted(d.rglob(glob) if glob else d.rglob("*")):
466
487
  if not f.is_file() or f.name == ".gitkeep":
467
488
  continue
489
+ if _is_blocked_path(f.relative_to(d)):
490
+ continue # skip .git / build / dependency caches
468
491
  # Skip binary-looking files
469
492
  if f.suffix in (".pyc", ".pyo", ".so", ".o", ".bin"):
470
493
  continue
@@ -1,39 +1,31 @@
1
1
  """Apply files from draft directory to project repo."""
2
2
 
3
+ import fnmatch
3
4
  import shutil
4
5
  import subprocess
5
- import sys
6
6
  from pathlib import Path
7
7
 
8
8
 
9
- def _probe(msg: str) -> None:
10
- """Diagnostic log for the step-dir-contains-whole-repo investigation.
11
-
12
- Prints to stderr with a greppable marker so it shows in `docker logs`.
13
- """
14
- print(f"[repo_apply.probe] {msg}", file=sys.stderr, flush=True)
15
-
16
-
17
9
  def repo_apply(source_dir: str, *, workspace_root: str = "",
18
10
  project_root: str = "",
19
11
  step_id: str = "", project_id: str = "",
20
- task_name: str = "") -> dict:
12
+ task_name: str = "", ignore=None) -> dict:
21
13
  src = Path(source_dir)
22
14
  if not src.is_absolute():
23
15
  src = Path(workspace_root) / source_dir
24
16
  src = src.resolve()
25
17
  dst = Path(project_root).resolve()
26
18
 
27
- # PROBE: what is repo_apply actually reading from, and is it the repo itself?
28
- _probe(f"step={step_id} project={project_id} task={task_name!r} "
29
- f"source_dir_param={source_dir!r} resolved_src={src} "
30
- f"project_root={dst} workspace_root={workspace_root!r} "
31
- f"src==dst? {src == dst}")
32
-
33
19
  if not src.exists():
34
20
  return {"applied": False, "files": [],
35
21
  "error": f"Source dir not found: {source_dir}"}
36
22
 
23
+ # Caller-supplied ignore globs (fnmatch), matched against BOTH the path
24
+ # relative to source_dir AND the basename — so "web/js/_x.json" and "_x.json"
25
+ # style patterns both work. Lets a host keep control/scratch files (e.g. a
26
+ # deletions manifest) in the step dir without them landing in the repo.
27
+ ignore_globs = tuple(ignore or ())
28
+
37
29
  applied_files = []
38
30
  for f in sorted(src.rglob("*")):
39
31
  if not f.is_file():
@@ -43,22 +35,21 @@ def repo_apply(source_dir: str, *, workspace_root: str = "",
43
35
  if ".git/" in str(f):
44
36
  continue
45
37
  rel = f.relative_to(src)
38
+ if ignore_globs and any(
39
+ fnmatch.fnmatch(str(rel), pat) or fnmatch.fnmatch(f.name, pat)
40
+ for pat in ignore_globs
41
+ ):
42
+ continue
46
43
  target = dst / rel
47
44
  target.parent.mkdir(parents=True, exist_ok=True)
48
45
  shutil.copy2(f, target)
49
46
  applied_files.append(str(rel))
50
47
 
51
- # PROBE: how many files did we just apply? A handful = normal surgical edit;
52
- # dozens/hundreds = the leak. Dump a sample so we can see WHAT leaked.
53
- if len(applied_files) > 15:
54
- _probe(f"LARGE APPLY step={step_id}: {len(applied_files)} files from "
55
- f"{src} — first 15: {applied_files[:15]}")
56
- else:
57
- _probe(f"step={step_id}: applied {len(applied_files)} file(s): {applied_files}")
58
-
59
48
  if not applied_files:
60
- return {"applied": False, "files": [],
61
- "error": "No files to apply (empty or all filtered)"}
49
+ # Empty source = a legitimate no-op step (the agent determined no change
50
+ # was needed and produced no output). Nothing to copy or commit; report
51
+ # success so the on_deliver hook doesn't retry/fail a clean no-op.
52
+ return {"applied": True, "files": [], "committed": False}
62
53
 
63
54
  # git add + commit
64
55
  r = subprocess.run(["git", "add", "-A"], cwd=dst,
@@ -8,3 +8,7 @@ parameters:
8
8
  type: string
9
9
  description: "Source directory path (absolute or relative to workspace)"
10
10
  required: true
11
+ ignore:
12
+ type: array
13
+ description: "Optional list of fnmatch globs (matched against the path relative to source_dir and the basename). Matching files are NOT copied to the repo — use for host control/scratch files kept in the step dir (e.g. a deletions manifest)."
14
+ required: false
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillflow-py
3
- Version: 1.3.2
3
+ Version: 1.4.0
4
4
  Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
5
5
  Author: Skillflow contributors
6
6
  License-Expression: MIT
@@ -76,6 +76,7 @@ tests/test_graph.py
76
76
  tests/test_integration.py
77
77
  tests/test_integration_configs.py
78
78
  tests/test_migration_regression.py
79
+ tests/test_noop_floor_fix.py
79
80
  tests/test_notifications.py
80
81
  tests/test_outbox.py
81
82
  tests/test_read_tools_exec.py
@@ -0,0 +1,65 @@
1
+ """Regression tests for the existing-repo "no-op floor" data-corruption bug.
2
+
3
+ Two skillflow-side guarantees the host (AItelier) relies on after the fix:
4
+
5
+ 1. ``repo_apply`` on an EMPTY source dir is a no-op SUCCESS, not an error.
6
+ A legitimate "no change needed" step promotes empty staging; on_deliver
7
+ must not retry/fail it.
8
+
9
+ 2. The generated directory read tools (``list_<label>`` / ``search_<label>``)
10
+ exclude ``.git`` and build/dependency caches and cap their output, so a
11
+ single ``list_repo_root`` can't dump the whole ``.git`` tree (tens of
12
+ thousands of tokens) into the agent's context.
13
+ """
14
+
15
+ import json
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from skillflow.read_tools import _is_blocked_path, make_read_tool_fns
20
+ from skillflow.tool_loader import ToolLoader
21
+
22
+ _REAL_TOOLS = Path(__file__).parent.parent / "src" / "skillflow" / "tools"
23
+
24
+
25
+ def test_repo_apply_empty_source_is_noop_success(tmp_path):
26
+ repo = tmp_path / "repo"
27
+ repo.mkdir()
28
+ subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
29
+
30
+ src = tmp_path / "empty_staging"
31
+ src.mkdir() # no files
32
+
33
+ repo_apply = ToolLoader(_REAL_TOOLS).load_fn("repo_apply")
34
+ res = repo_apply(str(src), project_root=str(repo))
35
+
36
+ assert res["applied"] is True
37
+ assert res["files"] == []
38
+ assert res.get("committed") is False
39
+ assert "error" not in res
40
+
41
+
42
+ def test_is_blocked_path_excludes_vcs_and_build_dirs():
43
+ assert _is_blocked_path(Path(".git/objects/ab/cd"))
44
+ assert _is_blocked_path("node_modules/foo/bar.js")
45
+ assert _is_blocked_path("pkg/__pycache__/x.pyc")
46
+ assert not _is_blocked_path("src/main.py")
47
+ assert not _is_blocked_path("docs/readme.md")
48
+
49
+
50
+ def test_list_repo_tool_excludes_git_tree(tmp_path):
51
+ repo = tmp_path / "repo"
52
+ (repo / ".git" / "objects").mkdir(parents=True)
53
+ (repo / ".git" / "objects" / "deadbeef").write_text("binblob")
54
+ (repo / "src").mkdir()
55
+ (repo / "src" / "main.py").write_text("x = 1\n")
56
+
57
+ specs = [{"source_type": "repository", "mode": "tool"}]
58
+ fns = make_read_tool_fns(specs, str(tmp_path), code_root=str(repo))
59
+
60
+ list_fn = next(fn for name, fn in fns.items() if name.startswith("list_"))
61
+ payload = json.loads(list_fn())
62
+ names = [e["name"] for e in payload["files"]]
63
+
64
+ assert "src/main.py" in names
65
+ assert not any(".git" in n for n in names), names
@@ -144,7 +144,67 @@ class TestRepoApply:
144
144
  result = repo_apply(source_dir=str(empty),
145
145
  workspace_root=str(tmp_path),
146
146
  project_root=str(proj))
147
- assert result["applied"] is False
147
+ # Empty source is a legitimate no-op step (agent determined no change
148
+ # needed): report success with no files, not an error that would make
149
+ # the on_deliver hook retry/fail a clean no-op.
150
+ assert result["applied"] is True
151
+ assert result["files"] == []
152
+ assert result.get("committed") is False
153
+
154
+ def test_ignore_globs_not_copied(self, tmp_path):
155
+ """Files matching an `ignore` glob stay out of the repo (host control
156
+ files kept in the step dir, e.g. a deletions manifest)."""
157
+ proj = tmp_path / "project"
158
+ proj.mkdir()
159
+ subprocess.run(["git", "init", "-b", "main"], cwd=proj,
160
+ capture_output=True)
161
+ subprocess.run(["git", "config", "user.email", "test@test.com"],
162
+ cwd=proj, capture_output=True)
163
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=proj,
164
+ capture_output=True)
165
+ (proj / ".gitkeep").write_text("")
166
+ subprocess.run(["git", "add", "."], cwd=proj, capture_output=True)
167
+ subprocess.run(["git", "commit", "-m", "init"], cwd=proj,
168
+ capture_output=True)
169
+
170
+ draft = tmp_path / "draft"
171
+ draft.mkdir()
172
+ (draft / "keep.py").write_text("x = 1\n")
173
+ (draft / "_deletions.json").write_text('["old.js"]')
174
+ (draft / "sub").mkdir()
175
+ (draft / "sub" / "_deletions.json").write_text('["y"]')
176
+
177
+ result = repo_apply(source_dir=str(draft),
178
+ workspace_root=str(tmp_path),
179
+ project_root=str(proj),
180
+ ignore=["_deletions.json"])
181
+
182
+ assert result["applied"] is True
183
+ assert "keep.py" in result["files"]
184
+ # basename-matched at any depth → never copied, never committed
185
+ assert "_deletions.json" not in result["files"]
186
+ assert "sub/_deletions.json" not in result["files"]
187
+ assert (proj / "keep.py").exists()
188
+ assert not (proj / "_deletions.json").exists()
189
+ assert not (proj / "sub" / "_deletions.json").exists()
190
+
191
+ def test_ignore_default_none_copies_everything(self, tmp_path):
192
+ """Backward-compat: no `ignore` arg → every file copied (unchanged)."""
193
+ proj = tmp_path / "project"
194
+ proj.mkdir()
195
+ subprocess.run(["git", "init", "-b", "main"], cwd=proj,
196
+ capture_output=True)
197
+ subprocess.run(["git", "config", "user.email", "t@t"], cwd=proj,
198
+ capture_output=True)
199
+ subprocess.run(["git", "config", "user.name", "T"], cwd=proj,
200
+ capture_output=True)
201
+ draft = tmp_path / "draft"
202
+ draft.mkdir()
203
+ (draft / "_deletions.json").write_text("[]")
204
+ result = repo_apply(source_dir=str(draft),
205
+ workspace_root=str(tmp_path),
206
+ project_root=str(proj))
207
+ assert "_deletions.json" in result["files"]
148
208
 
149
209
 
150
210
  class TestLint:
File without changes
File without changes
File without changes