Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Select a checkpoint row and click **Compare checkpoint to current**. AppTrail ca

Exclusions apply to hashing, metadata, and text capture. Enter one glob per line. Defaults skip `.git/`, `__pycache__/`, `.venv/`, and `node_modules/`. A name such as `build/` matches at any depth; `*.log` matches file or directory names at any depth. Patterns containing `/`, such as `src/generated/`, match from the project root. Matching is case-insensitive; `*` in a path pattern can span `/`. Matching directories are pruned. Negation and absolute/parent paths are unsupported. These are explicit AppTrail patterns, not `.gitignore` rules. Excluded paths do not appear as capture warnings or changes. Rules are saved with each checkpoint and reused for comparisons, including after restarting AppTrail.

With Git installed, checkpoint details show the full commit ID, branch (`HEAD` when detached), commit subject, author, commit date, and whether the repository has uncommitted or untracked changes. Git inspection is local and read-only; it does not fetch, checkout, or modify commits. Git status describes the whole repository, including files excluded from AppTrail. Without Git, outside a repository, or before the first commit, checkpoints still work and identify Git details as unavailable. The comparison HTML report includes Git evidence and exclusion rules for both observations.
With Git installed, checkpoint details show the full commit ID, branch (`HEAD` when detached), commit subject, author, commit date, and an explicit unknown working-tree state. Git inspection is local and read-only; it does not fetch, checkout, or modify commits. Working-tree status is not inspected. Without Git, outside a repository, or before the first commit, checkpoints still work and identify Git details as unavailable. The comparison HTML report includes Git evidence and exclusion rules for both observations.

A checkpoint is a sequential observation, not a backup or a Git commit. Git metadata is read after the files are captured; keep the project idle for consistent evidence. Renaming a checkpoint through history changes its display name without rewriting the original capture name or exclusion policy. Notes, Trash, and restore work as before. Project paths and source contents remain local unless you share an exported report or snapshot.

Expand Down Expand Up @@ -268,3 +268,5 @@ Reports retain their exported theme, syntax/word colors, whitespace filter, and
Use **Expand all unchanged** to reveal every unchanged section in both snapshots, or **Collapse all unchanged** to return to a compact review. The section counter shows how many are open. These buttons leave source text, syntax/word colors, search highlights, and change navigation intact. Searching or navigating to a hidden match reopens its section. Controls are disabled when the export contains no collapsed-context sections; export from the app with Collapse unchanged lines enabled to create them. Individual section summaries still work, including without JavaScript. Re-export older reports to get the buttons.

To keep temporary CI storage small, automatic pushes test/build without uploading a duplicate workflow artifact. Manual workflow runs retain their downloadable build artifact for one day. Published release downloads are separate and retained.

Git checkpoint capture observes commit metadata only; working-tree state is unknown. New snapshots store `dirty: null` with a reason. Legacy Boolean states remain readable; older AppTrail versions may reject these new snapshots.
111 changes: 95 additions & 16 deletions apptrail/checkpoints.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""Project checkpoint capture policy and read-only local Git observations."""
import fnmatch
import os
import re
import shutil
import threading
import time
from datetime import datetime
from pathlib import Path
import subprocess

Expand Down Expand Up @@ -29,25 +35,98 @@ def excluded(path, rules):
return False


def _git_executable(folder):
# Search absolute PATH entries only; Windows which() also searches CWD.
for entry in os.get_exec_path():
directory = Path(entry)
if not directory.is_absolute():
continue
candidate = shutil.which('git', path=str(directory))
if candidate:
executable = Path(candidate).resolve()
if (executable.parent == directory.resolve()
and not executable.is_relative_to(folder)):
return str(executable)
raise ValueError('Git metadata unavailable: no installed Git outside the project.')


def _git_output(command, environment, cwd, deadline):
if time.monotonic() >= deadline:
raise ValueError('Git metadata unavailable: observation timed out.')
buffers = [bytearray(), bytearray()]
overflow = threading.Event()
with subprocess.Popen(command, cwd=cwd, env=environment, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, shell=False,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) as process:
def collect(stream, buffer):
while chunk := stream.read1(4096):
if len(buffer) + len(chunk) > 65536:
overflow.set()
return
buffer.extend(chunk)
readers = [threading.Thread(target=collect, args=(stream, buffer), daemon=True)
for stream, buffer in zip((process.stdout, process.stderr), buffers)]
for reader in readers:
reader.start()
try:
while process.poll() is None or any(reader.is_alive() for reader in readers):
if overflow.is_set():
raise ValueError('Git metadata unavailable: output limit exceeded.')
if time.monotonic() >= deadline:
raise ValueError('Git metadata unavailable: observation timed out.')
overflow.wait(min(0.01, max(0, deadline - time.monotonic())))
if overflow.is_set():
raise ValueError('Git metadata unavailable: output limit exceeded.')
return process.returncode, buffers[0].decode('utf-8', errors='replace').strip()
finally:
if process.poll() is None:
process.kill()
process.wait()
for reader in readers:
reader.join(timeout=0.1)


def git_details(folder):
def git(*args):
result = subprocess.run(['git', '--no-optional-locks', '-C', str(folder), *args],
capture_output=True, text=True, encoding='utf-8', errors='replace',
timeout=10, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
if result.returncode:
raise ValueError('Git metadata unavailable (folder may not be a repository or have a commit).')
return result.stdout.strip()
try:
folder = Path(folder).resolve()
executable = _git_executable(folder)
environment = {k: v for k, v in os.environ.items() if not k.upper().startswith('GIT_')}
environment.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_SYSTEM=os.devnull,
GIT_CONFIG_GLOBAL=os.devnull, GIT_TERMINAL_PROMPT='0')
command = [executable, '--no-pager', '--no-optional-locks', '--no-replace-objects',
'--no-lazy-fetch', '-c', 'log.showSignature=false', '-c', 'log.mailmap=false',
'-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=' + os.devnull,
'-c', 'protocol.allow=never', '-C', str(folder)]
deadline = time.monotonic() + 10

def git(*args, detached=False):
code, output = _git_output(command + list(args), environment,
str(Path(executable).parent), deadline)
if detached and code == 1:
return 'HEAD'
if code:
raise ValueError('Git metadata unavailable (unsupported Git, repository, or commit).')
return output

top = git('rev-parse', '--show-toplevel')
commit = git('rev-parse', '--verify', 'HEAD')
return {'status': 'available', 'repository': top, 'commit': commit,
'branch': git('rev-parse', '--abbrev-ref', 'HEAD'),
'subject': git('log', '-1', '--format=%s'),
'author': git('log', '-1', '--format=%an'),
'committed_at': git('log', '-1', '--format=%cI'),
'dirty': bool(git('status', '--porcelain', '--untracked-files=normal'))}
except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
return {'status': 'unavailable', 'message': str(exc)}
commit = git('rev-parse', '--verify', 'HEAD^{commit}')
if not re.fullmatch(r'(?:[0-9a-f]{40}|[0-9a-f]{64})', commit):
raise ValueError('Git metadata unavailable: invalid commit ID.')
branch = git('symbolic-ref', '--quiet', '--short', 'HEAD', detached=True)
metadata = git('show', '--no-patch', '--no-show-signature',
'--format=%s%x00%an%x00%cI', commit, '--').split('\0')
if len(metadata) != 3:
raise ValueError('Git metadata unavailable: malformed commit metadata.')
subject, author, committed_at = metadata
if not re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})', committed_at):
raise ValueError('Git metadata unavailable: invalid commit timestamp.')
datetime.fromisoformat(committed_at)
return {'status': 'available', 'repository': top, 'commit': commit, 'branch': branch,
'subject': subject, 'author': author, 'committed_at': committed_at,
'dirty': None,
'dirty_reason': 'Working-tree state was not inspected; metadata-only capture.'}
except (OSError, ValueError) as exc:
return {'status': 'unavailable', 'message': str(exc)[:500]}


def capture_checkpoint(history, folder, name, exclusions, capture_text=True,
Expand Down
4 changes: 3 additions & 1 deletion apptrail/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ def load_snapshot(path):
if git.get('status') not in ('available', 'unavailable'):
raise ValueError('Invalid checkpoint Git status.')
if git['status'] == 'available':
if any(not isinstance(git.get(k), str) for k in ('repository', 'commit', 'branch', 'subject', 'author', 'committed_at')) or type(git.get('dirty')) is not bool:
if any(not isinstance(git.get(k), str) for k in ('repository', 'commit', 'branch', 'subject', 'author', 'committed_at')) or not (type(git.get('dirty')) is bool or ('dirty' in git and git['dirty'] is None
and isinstance(git.get('dirty_reason'), str) and git['dirty_reason'].strip()
and len(git['dirty_reason']) <= 500)):
raise ValueError('Invalid checkpoint Git details.')
elif not isinstance(git.get('message'), str):
raise ValueError('Invalid checkpoint Git message.')
Expand Down
2 changes: 1 addition & 1 deletion apptrail/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def history_selection_changed(self, event=None):

if len(selected) == 1 and selected[0] in self.history_entries and checkpoint:
git = checkpoint['git']
details = (f"Git {git['commit']} · {git['branch']} · {'uncommitted changes' if git['dirty'] else 'clean'}\n{git['subject'][:120]} · {git['author']} · {git['committed_at']}"
details = (f"Git {git['commit']} · {git['branch']} · {'uncommitted changes present' if git['dirty'] is True else 'clean working tree' if git['dirty'] is False else 'Working-tree state unknown — metadata-only capture.'}\n{git['subject'][:120]} · {git['author']} · {git['committed_at']}"
if git['status'] == 'available' else 'Git details unavailable')
self.selection_info.set(self.selection_info.get() + '\nCheckpoint · ' + details +
'\nExclusions: ' + (', '.join(checkpoint['exclusions']) or 'none'))
Expand Down
4 changes: 3 additions & 1 deletion apptrail/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ def file_details(entry):
if checkpoint:
git = checkpoint['git']
details = (f"{git['commit']} · {git['branch']} · {git['subject']} · {git['author']} · {git['committed_at']} · "
+ ('uncommitted changes present' if git['dirty'] else 'clean working tree')
+ ('uncommitted changes present' if git['dirty'] is True else
'clean working tree' if git['dirty'] is False else
'Working-tree state unknown — metadata-only capture.')
if git['status'] == 'available' else git.get('message', 'Git unavailable'))
notes += f'<p>Git: {e(details)}</p><p>Exclusions: {e(", ".join(checkpoint["exclusions"]) or "none")}</p>'
roots.append(f'<div><h2>{label.title()}</h2>{name}<p>{e(comparison[label]["root"])}</p>'
Expand Down
Loading