Make git logs easier for use in scenarios when communicating the progress of a project to non-experts.
- Generates readable markdown reports from Git commit history
- Processes test output (TAP format) and creates test documentation
- Multi-folder organization with unified index
📚 See Also:
- Obsidian Integration Guide - Detailed guide for using with Obsidian
- Development Guide - For contributors and developers
- Publishing Guide - How to publish to PyPI
- Quick Start - Quick reference
pip install lumpy-log# Clone the repository
git clone https://github.com/UTCSheffield/lumpy_log.git
cd lumpy_log
# Install in editable mode
pip install -e .After installation, you can use the lumpy-log command:
# Process current directory repository
lumpy-log log
# Process a specific repository
lumpy-log log -i /path/to/repo
# Process with options
lumpy-log log -i /path/to/repo -o devlog --verbose --force
# Backwards compatible (defaults to log command)
lumpy-log -i /path/to/repoLumpy Log can process test output in TAP (Test Anything Protocol) format and create markdown documentation alongside your commit logs.
Install pytest-tap plugin:
pip install pytest-tapBash/Linux/macOS:
# Pipe test output directly
pytest --tap | lumpy-log test
# Or save to file first
pytest --tap > test_output.txt
lumpy-log test --input test_output.txtWindows cmd.exe or PowerShell:
REM Pipe test output directly
py -m pytest --tap | lumpy-log test
REM Or save to file first
py -m pytest --tap > test_output.txt
lumpy-log test --input test_output.txt
REM Include raw output for debugging
py -m pytest --tap | lumpy-log test --raw-outputTest results are saved to output/tests/ with timestamp filenames (e.g., 20260118_1430.md), and the index is automatically updated to include both commits and test results.
If you manually modify or reorganize commit/test files, you can regenerate the index:
# Rebuild with default order (oldest first - development log style)
lumpy-log rebuild
# Rebuild with changelog order (newest first)
lumpy-log rebuild --changelogYou can also run it as a module:
python -m lumpy_log -i /path/to/repo -o output-i, --repo: Path to the local Git repository (default: current directory)-o, --outputfolder: Output folder for generated files (default: devlog)-f, --fromcommit: Start from this commit-t, --tocommit: End at this commit-a, --allbranches: Include all branches-v, --verbose: Verbose output-b, --branch: Specific branch to process--force: Force overwrite existing files-d, --dryrun: Dry run - don't write files-n, --no-obsidian-index: Don't generate index.md
-o, --outputfolder: Output folder for test results (default: devlog)--input: Input file with test output (if not specified, reads from stdin)-v, --verbose: Verbose output--raw-output: Include raw test output in the report
Rebuilds the unified index.md from existing commits and test results without re-processing git history or re-running tests.
# Rebuild index with default order (oldest first)
lumpy-log rebuild
# Rebuild with changelog order (newest first)
lumpy-log rebuild --changelog
# Rebuild from custom output folder
lumpy-log rebuild -o /path/to/output-o, --outputfolder: Output folder containing commits/ and tests/ (default: devlog)-v, --verbose: Verbose output--changelog: Use changelog order (newest first) instead of default (oldest first)
Lumpy Log organizes output into subdirectories:
devlog/
├── index.md # Unified index with commits and test results
├── commits/ # Git commit markdown files
│ ├── 20260118_1430_abc1234.md
│ └── 20260118_1500_def5678.md
└── tests/ # Test result markdown files
├── 20260118_1430.md
└── 20260118_1500.md
Lumpy Log respects a repository-level .lumpyignore file using the same syntax as .gitignore (git wildmatch patterns). By default, it ignores Markdown files (*.md) so documentation changes don't flood the logs. Add additional patterns to .lumpyignore at your repo root to skip files or folders.
Example .lumpyignore:
# Ignore Markdown (default)
*.md
# Ignore generated docs and build artifacts
docs/
dist/
*.tmp
To run the tests, use the following command:
pytestBy "Mr Eggleton" on 2026-01-18
# Abstracts out lineIsComment so we can print the results
def _lineIsComment(self, i):
line = self.lines[i]
if(self.verbose):
print(self.lang.name, "self.lang.comment_structure",self.lang.comment_structure)
comment_structure = self.lang.comment_structure
begin = comment_structure.get("begin")
end = comment_structure.get("end")
single = comment_structure.get("single")
# Multiline comments: treat lines with both begin and end as comment,
# and any line inside unmatched begin/end pairs as comment.
if begin:
try:
beginmatches = re.findall(begin, line)
endmatches = re.findall(end, line)
# If both markers appear on the same line, it's a comment line.
if len(beginmatches) and len(endmatches):
return True
# If this line is inside an open multiline comment, it's a comment.
if self._in_multiline_comment(i, begin, end):
return True
except Exception as Err:
print(type(Err), Err)
print(self.lang.comment_family, comment_structure)
# Single-line comments
if single:
try:
if re.search(single, line.strip()):
return True
except Exception as Err:
print("Single", type(Err), Err)
print(self.lang.comment_family, comment_structure["single"])
return False @property
def code(self):
start = self.start
if(self.commentStart is not None):
start = self.commentStart
#code = ""self.source+"\n"+
code = ("\n".join(self.lines[start: self.end+1]))
if self.verbose:
print("code", code)
return code def extendOverComments(self):
if self.verbose:
print("extendOverComments", "self.start", self.start)
j = self.start
while(j > 0 and self.lineIsComment(j-1)):
j -= 1
self.commentStart = j def lineIsComment(self, i):
blineIsComment = self._lineIsComment(i)
if self.verbose:
print("lineIsComment", blineIsComment, self.lines[i])
return blineIsComment def inLump(self,i):
inLump = (self.start <= i and i <= self.end)
if self.verbose:
print("inLump", "self.start", self.start,"i", i, "inLump",inLump)
return inLump """Return True if line i is inside an unmatched multiline comment block."""
try:
# Check if begin and end delimiters are the same (symmetric like """)
# Strip common regex anchors to compare the actual delimiter strings
begin_stripped = begin_re.strip('^$\\s')
end_stripped = end_re.strip('^$\\s')
symmetric = (begin_stripped == end_stripped)
in_comment = False
for idx in range(0, i + 1):
s = self.lines[idx]
if symmetric:
# For symmetric delimiters (like """ in Python), each occurrence
# toggles the comment state: first one opens, second one closes, etc.
# Example: """comment""" means we enter on first """, exit on second
matches = re.findall(begin_re, s)
for _ in matches:
in_comment = not in_comment # Flip True->False or False->True
else:
# For asymmetric delimiters, track depth
begins = len(re.findall(begin_re, s))
ends = len(re.findall(end_re, s))
# Process begins first, then ends
if not in_comment and begins > 0:
in_comment = True
if in_comment and ends > 0:
in_comment = False
return in_comment
except Exception as Err:
if self.verbose:
print("_in_multiline_comment error", type(Err), Err)
return FalseFormat: tap
- Tests Run: 113
- Passed: 113 ✅
- Failed: 0
- Skipped: 0