ghostbuster-cli 0.3.0__tar.gz → 0.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 (46) hide show
  1. ghostbuster_cli-0.4.0/.github/FUNDING.yml +4 -0
  2. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/.github/workflows/publish.yml +2 -3
  3. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/CHANGELOG.md +10 -0
  4. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/PKG-INFO +20 -1
  5. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/README.md +19 -0
  6. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/pyproject.toml +1 -1
  7. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/__init__.py +1 -1
  8. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/scan.py +28 -1
  9. ghostbuster_cli-0.4.0/src/ghostbuster/core/git_diff.py +80 -0
  10. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/scanner.py +15 -0
  11. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/test_cli.py +1 -1
  12. ghostbuster_cli-0.4.0/tests/test_git_diff.py +114 -0
  13. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  14. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/.gitignore +0 -0
  15. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/.pre-commit-hooks.yaml +0 -0
  16. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/CONTRIBUTING.md +0 -0
  17. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/LICENSE +0 -0
  18. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/assets/npm_package_icon.png +0 -0
  19. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/assets/readme_header.png +0 -0
  20. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/assets/terminal_icon.png +0 -0
  21. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/__main__.py +0 -0
  22. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/__init__.py +0 -0
  23. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/app.py +0 -0
  24. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/bust.py +0 -0
  25. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/display.py +0 -0
  26. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/config.py +0 -0
  27. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/__init__.py +0 -0
  28. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/dead_imports.py +0 -0
  29. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/models.py +0 -0
  30. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/orphan_files.py +0 -0
  31. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/phantom_env.py +0 -0
  32. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/scoring.py +0 -0
  33. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/zombie_code.py +0 -0
  34. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/fixers/__init__.py +0 -0
  35. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/fixers/env_fixer.py +0 -0
  36. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/fixers/gitignore_fixer.py +0 -0
  37. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/src/ghostbuster/fixers/import_fixer.py +0 -0
  38. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/__init__.py +0 -0
  39. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/conftest.py +0 -0
  40. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/test_dead_imports.py +0 -0
  41. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/test_fixers.py +0 -0
  42. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/test_node_scanner.py +0 -0
  43. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/test_orphan_files.py +0 -0
  44. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/test_phantom_env.py +0 -0
  45. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/test_scoring.py +0 -0
  46. {ghostbuster_cli-0.3.0 → ghostbuster_cli-0.4.0}/tests/test_zombie_code.py +0 -0
@@ -0,0 +1,4 @@
1
+ # GitHub Sponsor configuration for ghostbuster-cli
2
+ ko_fi: adewanggar
3
+ custom:
4
+ - https://saweria.co/adewanggar
@@ -3,9 +3,6 @@ name: Publish to PyPI
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
- push:
7
- tags:
8
- - "v*"
9
6
 
10
7
  jobs:
11
8
  pypi-publish:
@@ -33,3 +30,5 @@ jobs:
33
30
 
34
31
  - name: Publish package distributions to PyPI
35
32
  uses: pypa/gh-action-pypi-publish@release/v1
33
+ with:
34
+ skip-existing: true
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-08-29
11
+
12
+ ### Added
13
+
14
+ - **Git Diff / Incremental Fast Scan Mode (`ghostbuster scan --diff` / `-d`)**:
15
+ - Scan only modified, staged, and untracked files instead of the entire project tree.
16
+ - Added `--diff-base` flag to compare working branch against a target ref (e.g. `ghostbuster scan --diff-base origin/main`).
17
+ - Ideal for monorepos and GitHub Actions PR validation.
18
+ - Zero false positives: global reference verification remains intact for zombie code.
19
+
10
20
  ## [0.3.0] - 2026-08-29
11
21
 
12
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostbuster-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Find and bust the ghosts haunting your codebase - unused deps, dead code, orphan files, and phantom env vars.
5
5
  Project-URL: Homepage, https://github.com/adewanggar/ghostbuster-cli
6
6
  Project-URL: Documentation, https://github.com/adewanggar/ghostbuster-cli#readme
@@ -128,6 +128,18 @@ ghostbuster scan ./my-project
128
128
  ghostbuster scan -v
129
129
  ```
130
130
 
131
+ ### Incremental / Git Diff Scan
132
+
133
+ Scan only files that were modified, staged, or untracked in your working tree:
134
+
135
+ ```bash
136
+ # Scan modified files only (fast mode)
137
+ ghostbuster scan --diff
138
+
139
+ # Compare working branch against main/master
140
+ ghostbuster scan --diff-base origin/main
141
+ ```
142
+
131
143
  ### Filtered Scan
132
144
 
133
145
  ```bash
@@ -243,6 +255,13 @@ pip install pytest ruff mypy
243
255
  pytest tests/ -v
244
256
  ```
245
257
 
258
+ ## Support & Sponsor
259
+
260
+ If Ghostbuster helped clean up your codebase or saved CI minutes, consider supporting the project:
261
+
262
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20on%20Ko--fi-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/adewanggar)
263
+ [![Saweria](https://img.shields.io/badge/Saweria-Dukung%20lewat%20Saweria-FA8072?style=for-the-badge&logo=tether&logoColor=white)](https://saweria.co/adewanggar)
264
+
246
265
  ## License
247
266
 
248
267
  MIT (c) Ghostbuster Contributors
@@ -90,6 +90,18 @@ ghostbuster scan ./my-project
90
90
  ghostbuster scan -v
91
91
  ```
92
92
 
93
+ ### Incremental / Git Diff Scan
94
+
95
+ Scan only files that were modified, staged, or untracked in your working tree:
96
+
97
+ ```bash
98
+ # Scan modified files only (fast mode)
99
+ ghostbuster scan --diff
100
+
101
+ # Compare working branch against main/master
102
+ ghostbuster scan --diff-base origin/main
103
+ ```
104
+
93
105
  ### Filtered Scan
94
106
 
95
107
  ```bash
@@ -205,6 +217,13 @@ pip install pytest ruff mypy
205
217
  pytest tests/ -v
206
218
  ```
207
219
 
220
+ ## Support & Sponsor
221
+
222
+ If Ghostbuster helped clean up your codebase or saved CI minutes, consider supporting the project:
223
+
224
+ [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20on%20Ko--fi-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/adewanggar)
225
+ [![Saweria](https://img.shields.io/badge/Saweria-Dukung%20lewat%20Saweria-FA8072?style=for-the-badge&logo=tether&logoColor=white)](https://saweria.co/adewanggar)
226
+
208
227
  ## License
209
228
 
210
229
  MIT (c) Ghostbuster Contributors
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ghostbuster-cli"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Find and bust the ghosts haunting your codebase - unused deps, dead code, orphan files, and phantom env vars."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Ghostbuster — Find and bust the ghosts haunting your codebase."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -23,6 +23,7 @@ from ghostbuster.cli.display import (
23
23
  print_scan_result_markdown,
24
24
  print_scanning_start,
25
25
  )
26
+ from ghostbuster.core.git_diff import get_changed_files
26
27
  from ghostbuster.core.models import GhostCategory
27
28
  from ghostbuster.core.scanner import create_default_orchestrator
28
29
 
@@ -55,6 +56,17 @@ def scan(
55
56
  "-c",
56
57
  help="Only scan specific categories (dead-import, orphan-file, zombie-code, phantom-env).",
57
58
  ),
59
+ diff: bool = typer.Option(
60
+ False,
61
+ "--diff",
62
+ "-d",
63
+ help="Incremental fast scan: only scan modified, staged, and untracked files.",
64
+ ),
65
+ diff_base: str | None = typer.Option(
66
+ None,
67
+ "--diff-base",
68
+ help="Compare changed files against a specific git ref/branch (e.g. main, origin/main, HEAD~1).",
69
+ ),
58
70
  verbose: bool = typer.Option(
59
71
  False,
60
72
  "--verbose",
@@ -74,6 +86,8 @@ def scan(
74
86
 
75
87
  Examples:
76
88
  ghostbuster scan
89
+ ghostbuster scan --diff
90
+ ghostbuster scan --diff-base origin/main
77
91
  ghostbuster scan ./my-project --verbose
78
92
  ghostbuster scan --format json --category dead-import
79
93
  """
@@ -91,10 +105,19 @@ def scan(
91
105
  )
92
106
  raise typer.Exit(1)
93
107
 
108
+ # Handle diff mode
109
+ changed_files: set[Path] | None = None
110
+ if diff or diff_base:
111
+ changed_files = get_changed_files(path, base_ref=diff_base)
112
+
94
113
  # Show banner for rich format
95
114
  if format == OutputFormat.rich:
96
115
  print_banner()
97
116
  print_scanning_start(str(path), _get_scanner_names(category))
117
+ if diff or diff_base:
118
+ console.print(
119
+ f" [cyan]Diff mode active ({len(changed_files or set())} changed files analyzed)[/cyan]\n"
120
+ )
98
121
 
99
122
  # Run the scan
100
123
  try:
@@ -105,7 +128,11 @@ def scan(
105
128
  if format == OutputFormat.rich
106
129
  else _nullcontext()
107
130
  ):
108
- result = orchestrator.run(path, categories=category)
131
+ result = orchestrator.run(
132
+ path,
133
+ categories=category,
134
+ changed_files=changed_files if (diff or diff_base) else None,
135
+ )
109
136
 
110
137
  except Exception as exc:
111
138
  print_error(f"Scan failed: {exc}", "Run with --debug flag for full traceback.")
@@ -0,0 +1,80 @@
1
+ """Git Diff helper - detects changed and untracked files in git repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ def get_changed_files(path: Path, base_ref: str | None = None) -> set[Path]:
10
+ """Get the set of changed, added, and untracked files in a git repository.
11
+
12
+ Args:
13
+ path: Root path of the project.
14
+ base_ref: Optional git reference/branch to compare against (e.g. 'main', 'origin/main', 'HEAD~1').
15
+
16
+ Returns:
17
+ A set of absolute Paths for all modified/added/untracked files.
18
+ """
19
+ changed: set[Path] = set()
20
+
21
+ # Verify directory is a git repository
22
+ try:
23
+ check_git = subprocess.run(
24
+ ["git", "rev-parse", "--is-inside-work-tree"],
25
+ cwd=path,
26
+ capture_output=True,
27
+ text=True,
28
+ check=False,
29
+ )
30
+ if check_git.returncode != 0:
31
+ return changed
32
+ except (OSError, FileNotFoundError):
33
+ return changed
34
+
35
+ commands: list[list[str]] = []
36
+
37
+ if base_ref:
38
+ # Compare current working state or branch against base_ref
39
+ commands.append(["git", "diff", "--name-only", "--diff-filter=ACMR", base_ref])
40
+ else:
41
+ # Unstaged changes in working tree
42
+ commands.append(["git", "diff", "--name-only", "--diff-filter=ACMR"])
43
+ # Staged changes in index
44
+ commands.append(["git", "diff", "--name-only", "--cached", "--diff-filter=ACMR"])
45
+
46
+ # Also capture untracked files from git status
47
+ commands.append(["git", "status", "--porcelain"])
48
+
49
+ for cmd in commands:
50
+ try:
51
+ res = subprocess.run(
52
+ cmd,
53
+ cwd=path,
54
+ capture_output=True,
55
+ text=True,
56
+ check=False,
57
+ )
58
+ if res.returncode == 0:
59
+ for line in res.stdout.splitlines():
60
+ line = line.strip()
61
+ if not line:
62
+ continue
63
+
64
+ # Parse git status --porcelain output: '?? path/to/file' or 'M path/to/file'
65
+ if cmd[1] == "status":
66
+ parts = line.split(maxsplit=1)
67
+ if len(parts) == 2:
68
+ rel_file = parts[1].strip('"')
69
+ file_path = (path / rel_file).resolve()
70
+ if file_path.exists():
71
+ changed.add(file_path)
72
+ else:
73
+ # Parse git diff --name-only output
74
+ file_path = (path / line).resolve()
75
+ if file_path.exists():
76
+ changed.add(file_path)
77
+ except OSError:
78
+ continue
79
+
80
+ return changed
@@ -52,6 +52,7 @@ class ScanOrchestrator:
52
52
  self,
53
53
  path: Path,
54
54
  categories: list[str] | None = None,
55
+ changed_files: set[Path] | None = None,
55
56
  ) -> ScanResult:
56
57
  """Run all registered scanners (or filtered subset) on the given path.
57
58
 
@@ -59,6 +60,8 @@ class ScanOrchestrator:
59
60
  path: Root directory to scan.
60
61
  categories: Optional list of category names to filter scanners.
61
62
  If None, all scanners are run.
63
+ changed_files: Optional set of changed file Paths from git diff.
64
+ If provided, results will be filtered to these files.
62
65
 
63
66
  Returns:
64
67
  Aggregated ScanResult with all ghosts found and a score.
@@ -86,6 +89,18 @@ class ScanOrchestrator:
86
89
  )
87
90
  )
88
91
 
92
+ # In diff mode, filter ghosts to only those originating in changed files
93
+ if changed_files is not None:
94
+ resolved_changed = {p.resolve() for p in changed_files}
95
+ filtered_ghosts: list[Ghost] = []
96
+ for g in all_ghosts:
97
+ if g.file_path and g.file_path.resolve() in resolved_changed:
98
+ filtered_ghosts.append(g)
99
+ elif not g.file_path:
100
+ # Keep findings without a specific file path (general warnings)
101
+ filtered_ghosts.append(g)
102
+ all_ghosts = filtered_ghosts
103
+
89
104
  elapsed_ms = (time.perf_counter() - start) * 1000
90
105
  score = calculate_score(all_ghosts)
91
106
 
@@ -17,7 +17,7 @@ class TestCLI:
17
17
  result = runner.invoke(app, ["--version"])
18
18
  assert result.exit_code == 0
19
19
  assert "ghostbuster" in result.output
20
- assert "0.3.0" in result.output
20
+ assert "0.4.0" in result.output
21
21
 
22
22
  def test_help_flag(self) -> None:
23
23
  """--help should print help text."""
@@ -0,0 +1,114 @@
1
+ """Tests for Git Diff / Fast Scan mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from typer.testing import CliRunner
9
+
10
+ from ghostbuster.cli.app import app
11
+ from ghostbuster.core.git_diff import get_changed_files
12
+ from ghostbuster.core.models import Ghost, GhostCategory, Severity
13
+ from ghostbuster.core.scanner import ScanOrchestrator
14
+
15
+ runner = CliRunner()
16
+
17
+
18
+ class TestGitDiffHelper:
19
+ """Tests for get_changed_files."""
20
+
21
+ def test_non_git_directory_returns_empty_set(self, tmp_path: Path) -> None:
22
+ """Should return empty set for a non-git directory."""
23
+ changed = get_changed_files(tmp_path)
24
+ assert len(changed) == 0
25
+
26
+ def test_detects_untracked_and_modified_files(self, tmp_path: Path) -> None:
27
+ """Should detect untracked and modified files in a git repo."""
28
+ # Initialize git repo in tmp_path
29
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
30
+ subprocess.run(
31
+ ["git", "config", "user.email", "test@example.com"],
32
+ cwd=tmp_path,
33
+ capture_output=True,
34
+ check=True,
35
+ )
36
+ subprocess.run(
37
+ ["git", "config", "user.name", "Test User"],
38
+ cwd=tmp_path,
39
+ capture_output=True,
40
+ check=True,
41
+ )
42
+
43
+ # Create committed file
44
+ file1 = tmp_path / "initial.py"
45
+ file1.write_text("x = 1\n", encoding="utf-8")
46
+ subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True, check=True)
47
+ subprocess.run(
48
+ ["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True, check=True
49
+ )
50
+
51
+ # Modify file1 and create untracked file2
52
+ file1.write_text("x = 2\n", encoding="utf-8")
53
+ file2 = tmp_path / "untracked.py"
54
+ file2.write_text("y = 3\n", encoding="utf-8")
55
+
56
+ changed = get_changed_files(tmp_path)
57
+ assert file1.resolve() in changed
58
+ assert file2.resolve() in changed
59
+
60
+
61
+ class TestScanOrchestratorDiffFilter:
62
+ """Tests for ScanOrchestrator filtering with changed_files."""
63
+
64
+ def test_filters_ghosts_to_changed_files(self, tmp_path: Path) -> None:
65
+ """Should only return ghosts originating in changed_files."""
66
+ file_a = tmp_path / "a.py"
67
+ file_b = tmp_path / "b.py"
68
+ file_a.write_text("pass\n", encoding="utf-8")
69
+ file_b.write_text("pass\n", encoding="utf-8")
70
+
71
+ ghosts = [
72
+ Ghost(
73
+ category=GhostCategory.ZOMBIE_CODE,
74
+ name="unused_a",
75
+ message="unused in a",
76
+ file_path=file_a,
77
+ severity=Severity.LOW,
78
+ ),
79
+ Ghost(
80
+ category=GhostCategory.ZOMBIE_CODE,
81
+ name="unused_b",
82
+ message="unused in b",
83
+ file_path=file_b,
84
+ severity=Severity.LOW,
85
+ ),
86
+ ]
87
+
88
+ class MockScanner:
89
+ name = "mock"
90
+
91
+ def scan(self, path: Path) -> list[Ghost]:
92
+ return ghosts
93
+
94
+ orchestrator = ScanOrchestrator()
95
+ orchestrator.register(MockScanner())
96
+
97
+ # Only file_a changed
98
+ result = orchestrator.run(tmp_path, changed_files={file_a})
99
+ assert len(result.ghosts) == 1
100
+ assert result.ghosts[0].name == "unused_a"
101
+
102
+
103
+ class TestScanCliDiffFlag:
104
+ """Tests for CLI --diff flag."""
105
+
106
+ def test_cli_diff_option_help(self) -> None:
107
+ """--help should include --diff and --diff-base."""
108
+ import re
109
+
110
+ result = runner.invoke(app, ["scan", "--help"])
111
+ assert result.exit_code == 0
112
+ clean_output = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", result.output)
113
+ assert "--diff" in clean_output
114
+ assert "--diff-base" in clean_output
File without changes