Skip to content

Commit c656635

Browse files
Add option to output digest to stdout (#264)
* Add option to output digest to stdout This change introduces the ability for users to direct the output of the gitingest tool to standard output (stdout) instead of writing to a file. This is useful for piping the output to other commands or viewing it directly in the terminal. Co-authored-by: Filip Christiansen <22807962+filipchristiansen@users.noreply.github.com>
1 parent 2dea7c8 commit c656635

File tree

5 files changed

+130
-52
lines changed

5 files changed

+130
-52
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ repos:
4444
- id: black
4545

4646
- repo: https://github.com/asottile/pyupgrade
47-
rev: v3.19.1
47+
rev: v3.20.0
4848
hooks:
4949
- id: pyupgrade
5050
description: "Automatically upgrade syntax for newer versions."
@@ -73,7 +73,7 @@ repos:
7373
- id: djlint-reformat-jinja
7474

7575
- repo: https://github.com/igorshubovych/markdownlint-cli
76-
rev: v0.44.0
76+
rev: v0.45.0
7777
hooks:
7878
- id: markdownlint
7979
description: "Lint markdown files."
@@ -88,7 +88,7 @@ repos:
8888
files: ^src/
8989

9090
- repo: https://github.com/pycqa/pylint
91-
rev: v3.3.6
91+
rev: v3.3.7
9292
hooks:
9393
- id: pylint
9494
name: pylint for source

README.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,26 +78,35 @@ Issues and feature requests are welcome to the repo.
7878
The `gitingest` command line tool allows you to analyze codebases and create a text dump of their contents.
7979

8080
```bash
81-
# Basic usage
81+
# Basic usage (writes to digest.txt by default)
8282
gitingest /path/to/directory
8383

8484
# From URL
8585
gitingest https://github.com/cyclotruc/gitingest
86+
```
87+
88+
For private repositories, use the `--token/-t` option.
8689

87-
# For private repositories, use the --token option
90+
```bash
8891
# Get your token from https://github.com/settings/personal-access-tokens
8992
gitingest https://github.com/username/private-repo --token github_pat_...
9093

9194
# Or set it as an environment variable
9295
export GITHUB_TOKEN=github_pat_...
9396
gitingest https://github.com/username/private-repo
97+
```
9498

95-
# See more options
99+
By default, the digest is written to a text file (`digest.txt`) in your current working directory. You can customize the output in two ways:
100+
101+
- Use `--output/-o <filename>` to write to a specific file.
102+
- Use `--output/-o -` to output directly to `STDOUT` (useful for piping to other tools).
103+
104+
See more options and usage details with:
105+
106+
```bash
96107
gitingest --help
97108
```
98109

99-
This will write the digest in a text file (default `digest.txt`) in your current working directory.
100-
101110
## 🐍 Python package usage
102111

103112
```python
@@ -110,6 +119,18 @@ summary, tree, content = ingest("path/to/directory")
110119
summary, tree, content = ingest("https://github.com/cyclotruc/gitingest")
111120
```
112121

122+
For private repositories, you can pass a token:
123+
124+
```python
125+
# Using token parameter
126+
summary, tree, content = ingest("https://github.com/username/private-repo", token="github_pat_...")
127+
128+
# Or set it as an environment variable
129+
import os
130+
os.environ["GITHUB_TOKEN"] = "github_pat_..."
131+
summary, tree, content = ingest("https://github.com/username/private-repo")
132+
```
133+
113134
By default, this won't write a file but can be enabled with the `output` argument.
114135

115136
```python

src/gitingest/cli.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ def main(
7373
source : str
7474
A directory path or a Git repository URL.
7575
output : str, optional
76-
Output file path. Defaults to `<repo_name>.txt`.
76+
The path where the output file will be written. If not specified, the output will be written
77+
to a file named `<repo_name>.txt` in the current directory. Use '-' to output to stdout.
7778
max_size : int
7879
Maximum file size (in bytes) to consider.
7980
exclude_pattern : Tuple[str, ...]
@@ -113,14 +114,16 @@ async def _async_main(
113114
Analyze a directory or repository and create a text dump of its contents.
114115
115116
This command analyzes the contents of a specified source directory or repository, applies custom include and
116-
exclude patterns, and generates a text summary of the analysis which is then written to an output file.
117+
exclude patterns, and generates a text summary of the analysis which is then written to an output file
118+
or printed to stdout.
117119
118120
Parameters
119121
----------
120122
source : str
121123
A directory path or a Git repository URL.
122124
output : str, optional
123-
Output file path. Defaults to `<repo_name>.txt`.
125+
The path where the output file will be written. If not specified, the output will be written
126+
to a file named `<repo_name>.txt` in the current directory. Use '-' to output to stdout.
124127
max_size : int
125128
Maximum file size (in bytes) to consider.
126129
exclude_pattern : Tuple[str, ...]
@@ -143,23 +146,32 @@ async def _async_main(
143146
exclude_patterns = set(exclude_pattern)
144147
include_patterns = set(include_pattern)
145148

146-
# Choose a default output path if none provided
147-
if output is None:
148-
output = OUTPUT_FILE_NAME
149+
output_target = output if output is not None else OUTPUT_FILE_NAME
150+
151+
if output_target == "-":
152+
click.echo("Analyzing source, preparing output for stdout...", err=True)
153+
else:
154+
click.echo(f"Analyzing source, output will be written to '{output_target}'...", err=True)
149155

150156
summary, _, _ = await ingest_async(
151157
source=source,
152158
max_file_size=max_size,
153159
include_patterns=include_patterns,
154160
exclude_patterns=exclude_patterns,
155161
branch=branch,
156-
output=output,
162+
output=output_target,
157163
token=token,
158164
)
159165

160-
click.echo(f"Analysis complete! Output written to: {output}")
161-
click.echo("\nSummary:")
162-
click.echo(summary)
166+
if output_target == "-": # stdout
167+
click.echo("\n--- Summary ---", err=True)
168+
click.echo(summary, err=True)
169+
click.echo("--- End Summary ---", err=True)
170+
click.echo("Analysis complete! Output sent to stdout.", err=True)
171+
else: # file
172+
click.echo(f"Analysis complete! Output written to: {output_target}")
173+
click.echo("\nSummary:")
174+
click.echo(summary)
163175

164176
except Exception as exc:
165177
# Convert any exception into Click.Abort so that exit status is non-zero

src/gitingest/entrypoint.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import inspect
55
import os
66
import shutil
7+
import sys
78
from typing import Optional, Set, Tuple, Union
89

910
from gitingest.cloning import clone_repo
@@ -93,7 +94,12 @@ async def ingest_async(
9394

9495
summary, tree, content = ingest_query(query)
9596

96-
if output is not None:
97+
if output == "-":
98+
loop = asyncio.get_running_loop()
99+
output_data = tree + "\n" + content
100+
await loop.run_in_executor(None, sys.stdout.write, output_data)
101+
await loop.run_in_executor(None, sys.stdout.flush)
102+
elif output is not None:
97103
with open(output, "w", encoding="utf-8") as f:
98104
f.write(tree + "\n" + content)
99105

tests/test_cli.py

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,80 @@
1-
"""Tests for the gitingest cli."""
1+
"""Tests for the Gitingest CLI."""
22

33
import os
4+
from inspect import signature
5+
from pathlib import Path
6+
from typing import List
47

5-
from click.testing import CliRunner
8+
import pytest
9+
from _pytest.monkeypatch import MonkeyPatch
10+
from click.testing import CliRunner, Result
611

712
from gitingest.cli import main
813
from gitingest.config import MAX_FILE_SIZE, OUTPUT_FILE_NAME
914

1015

11-
def test_cli_with_default_options():
12-
runner = CliRunner()
13-
result = runner.invoke(main, ["./"])
14-
output_lines = result.output.strip().split("\n")
15-
assert f"Analysis complete! Output written to: {OUTPUT_FILE_NAME}" in output_lines
16-
assert os.path.exists(OUTPUT_FILE_NAME), f"Output file was not created at {OUTPUT_FILE_NAME}"
17-
18-
os.remove(OUTPUT_FILE_NAME)
19-
20-
21-
def test_cli_with_options():
22-
runner = CliRunner()
23-
result = runner.invoke(
24-
main,
25-
[
26-
"./",
27-
"--output",
28-
str(OUTPUT_FILE_NAME),
29-
"--max-size",
30-
str(MAX_FILE_SIZE),
31-
"--exclude-pattern",
32-
"tests/",
33-
"--include-pattern",
34-
"src/",
35-
],
36-
)
37-
output_lines = result.output.strip().split("\n")
38-
assert f"Analysis complete! Output written to: {OUTPUT_FILE_NAME}" in output_lines
39-
assert os.path.exists(OUTPUT_FILE_NAME), f"Output file was not created at {OUTPUT_FILE_NAME}"
40-
41-
os.remove(OUTPUT_FILE_NAME)
16+
@pytest.mark.parametrize(
17+
"cli_args, expect_file",
18+
[
19+
pytest.param(["./"], True, id="default-options"),
20+
pytest.param(
21+
[
22+
"./",
23+
"--output",
24+
str(OUTPUT_FILE_NAME),
25+
"--max-size",
26+
str(MAX_FILE_SIZE),
27+
"--exclude-pattern",
28+
"tests/",
29+
"--include-pattern",
30+
"src/",
31+
],
32+
True,
33+
id="custom-options",
34+
),
35+
],
36+
)
37+
def test_cli_writes_file(tmp_path: Path, monkeypatch: MonkeyPatch, cli_args: List[str], expect_file: bool) -> None:
38+
"""Run the CLI and verify that the SARIF file is created (or not)."""
39+
# Work inside an isolated temp directory
40+
monkeypatch.chdir(tmp_path)
41+
42+
result = _invoke_isolated_cli_runner(cli_args)
43+
44+
assert result.exit_code == 0, result.stderr
45+
46+
# Summary line should be on STDOUT
47+
stdout_lines = result.stdout.splitlines()
48+
assert f"Analysis complete! Output written to: {OUTPUT_FILE_NAME}" in stdout_lines
49+
50+
# File side-effect
51+
sarif_file = tmp_path / OUTPUT_FILE_NAME
52+
assert sarif_file.exists() is expect_file, f"{OUTPUT_FILE_NAME} existence did not match expectation"
53+
54+
55+
def test_cli_with_stdout_output() -> None:
56+
"""Test CLI invocation with output directed to STDOUT."""
57+
result = _invoke_isolated_cli_runner(["./", "--output", "-", "--exclude-pattern", "tests/"])
58+
59+
# ─── core expectations (stdout) ────────────────────────────────────-
60+
assert result.exit_code == 0, f"CLI exited with code {result.exit_code}, stderr: {result.stderr}"
61+
assert "---" in result.stdout, "Expected file separator '---' not found in STDOUT"
62+
assert "src/gitingest/cli.py" in result.stdout, "Expected content (e.g., src/gitingest/cli.py) not found in STDOUT"
63+
assert not os.path.exists(OUTPUT_FILE_NAME), f"Output file {OUTPUT_FILE_NAME} was unexpectedly created."
64+
65+
# ─── the summary must *not* pollute STDOUT, must appear on STDERR ───
66+
summary = "Analysis complete! Output sent to stdout."
67+
stdout_lines = result.stdout.splitlines()
68+
stderr_lines = result.stderr.splitlines()
69+
assert summary not in stdout_lines, "Unexpected summary message found in STDOUT"
70+
assert summary in stderr_lines, "Expected summary message not found in STDERR"
71+
assert f"Output written to: {OUTPUT_FILE_NAME}" not in stderr_lines
72+
73+
74+
def _invoke_isolated_cli_runner(args: List[str]) -> Result:
75+
"""Return a CliRunner that keeps stderr apart on Click 8.0-8.1."""
76+
kwargs = {}
77+
if "mix_stderr" in signature(CliRunner.__init__).parameters:
78+
kwargs["mix_stderr"] = False # Click 8.0–8.1
79+
runner = CliRunner(**kwargs)
80+
return runner.invoke(main, args)

0 commit comments

Comments
 (0)