Add Zed Hours Tracker tooling
Introduces a local-first time tracker for Zed editor sessions, including background activity tracking, automatic git commit logging, and markdown report generation. Includes install script, systemd service template, and setup instructions in README.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
# Zed Hours Tracker
|
||||
|
||||
A lightweight, local-first, background time tracker for Zed editor sessions, with automatic git commit logging. No cloud, no accounts, no manual timers.
|
||||
|
||||
Works on **Arch Linux + Wayland** and is designed to be portable to other projects.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Detects when you are working in Zed** on this project by monitoring the active window title (Wayland) and filesystem activity.
|
||||
2. **Writes heartbeats** to `.zed-hours/heartbeats.jsonl`:
|
||||
- timestamp
|
||||
- project path
|
||||
- focused file (if detectable)
|
||||
- idle status
|
||||
3. **Logs git commits** automatically via `post-commit` hook to `.zed-hours/commits.jsonl`.
|
||||
4. **Generates markdown reports** combining heartbeats + commits into daily summaries.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `install.py` | Install git hook and create `.zed-hours/` data directory |
|
||||
| `log_commit.py` | Called by git hook; do not run directly usually |
|
||||
| `zed_tracker.py` | Background tracker for Zed activity |
|
||||
| `generate_report.py` | Generate a markdown hours report |
|
||||
| `README.md` | This file |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Run from the project root:
|
||||
|
||||
```bash
|
||||
python3 tools/time_tracking/install.py
|
||||
```
|
||||
|
||||
This will:
|
||||
- create `.zed-hours/` in the project root;
|
||||
- install a `post-commit` git hook that logs commits;
|
||||
- create a default config file.
|
||||
|
||||
---
|
||||
|
||||
## Running the tracker
|
||||
|
||||
The tracker is intentionally a simple script you run in the background.
|
||||
|
||||
### Option A: One-off test
|
||||
|
||||
```bash
|
||||
python3 tools/time_tracking/zed_tracker.py --project-root $(pwd)
|
||||
```
|
||||
|
||||
Press `Ctrl+C` to stop.
|
||||
|
||||
### Option B: Zed tasks (run from the editor)
|
||||
|
||||
This project includes Zed tasks so you can trigger tracker actions with the task runner shortcut (`alt+shift+t` or `ctrl+shift+t` depending on your keymap). The default working directory for Zed tasks is the project root, so the commands use relative paths.
|
||||
|
||||
| Task | Action |
|
||||
|---|---|
|
||||
| `Zed Hours: Install tracker` | Install git hook, config, and systemd service |
|
||||
| `Zed Hours: Start tracker (foreground)` | Start the tracker in a Zed terminal panel |
|
||||
| `Zed Hours: Generate report (today)` | Generate today's report |
|
||||
| `Zed Hours: Generate report (yesterday)` | Generate yesterday's report |
|
||||
| `Zed Hours: Generate report (last 14 days)` | Generate a 14-day report |
|
||||
| `Zed Hours: Open latest report` | Print path to latest report |
|
||||
| `Zed Hours: Status` | Show heartbeat/commit/report counts |
|
||||
|
||||
### Option C: Background via systemd user service (recommended for daily use)
|
||||
|
||||
A sample service file is generated by `install.py` at:
|
||||
|
||||
```
|
||||
~/.config/systemd/user/zed-hours-gem360.service
|
||||
```
|
||||
|
||||
Enable it:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now zed-hours-gem360.service
|
||||
systemctl --user status zed-hours-gem360.service
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
journalctl --user -u zed-hours-gem360.service -f
|
||||
```
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
systemctl --user stop zed-hours-gem360.service
|
||||
```
|
||||
|
||||
### Option C: `tmux` / `screen`
|
||||
|
||||
```bash
|
||||
tmux new -s zed-hours -d 'python3 tools/time_tracking/zed_tracker.py --project-root $(pwd)'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generating a report
|
||||
|
||||
```bash
|
||||
python3 tools/time_tracking/generate_report.py --from 2026-08-03 --to yesterday
|
||||
```
|
||||
|
||||
Output is written to `.zed-hours/reports/YYYY-MM-DD_to_YYYY-MM-DD.md`.
|
||||
|
||||
You can also run:
|
||||
|
||||
```bash
|
||||
python3 tools/time_tracking/generate_report.py --today
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How activity detection works on Wayland
|
||||
|
||||
Wayland does not expose a global active-window API for security reasons. The tracker tries several methods, in order:
|
||||
|
||||
1. **Hyprland**: `hyprctl activewindow`
|
||||
2. **Sway**: `swaymsg -t get_tree`
|
||||
3. **GNOME / Mutter**: D-Bus `GetActiveWindow` via `gdbus`
|
||||
4. **KDE / KWin**: D-Bus via `qdbus`
|
||||
5. **Fallback**: monitor filesystem activity inside the project root using `inotify` or polling recent file access/modification times.
|
||||
|
||||
If all window-detection methods fail, the fallback still gives you a reliable signal that someone is actively editing files in this project.
|
||||
|
||||
---
|
||||
|
||||
## Reusing in another project
|
||||
|
||||
Copy the `tools/time_tracking/` directory to the new project and run:
|
||||
|
||||
```bash
|
||||
python3 tools/time_tracking/install.py
|
||||
```
|
||||
|
||||
The scripts detect the project root automatically from the current working directory.
|
||||
|
||||
---
|
||||
|
||||
## Data format
|
||||
|
||||
### `.zed-hours/heartbeats.jsonl`
|
||||
|
||||
```json
|
||||
{"timestamp": "2026-08-26T09:15:00", "project": "/home/fegger/Code/ixsol/gem360-git", "file": "README.md", "active": true, "source": "hyprctl"}
|
||||
```
|
||||
|
||||
### `.zed-hours/commits.jsonl`
|
||||
|
||||
```json
|
||||
{"type": "commit", "timestamp": "2026-08-26T09:20:00", "repo": "/home/fegger/Code/ixsol/gem360-git", "branch": "va_module", "sha": "abc12345", "message": "[ADD] feature X", "author": "Florian Egger"}
|
||||
```
|
||||
|
||||
### `.zed-hours/config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"project_root": "/home/fegger/Code/ixsol/gem360-git",
|
||||
"heartbeat_interval_seconds": 60,
|
||||
"idle_threshold_seconds": 300,
|
||||
"log_dir": ".zed-hours"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This tool is intentionally simple and local. It is not a replacement for a commercial time tracker, but it is a reliable fallback when you forget to log hours.
|
||||
- The tracker does not send data anywhere.
|
||||
- Heartbeat granularity is one minute by default. Tune it in `config.json`.
|
||||
- Because of Wayland security, window detection may not give the exact filename. The filesystem fallback compensates for this.
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a markdown working-hours report from .zed-hours/*.jsonl."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_iso(ts: str) -> datetime:
|
||||
# Python < 3.11 compatibility for 'Z' suffix
|
||||
ts = ts.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(ts)
|
||||
|
||||
|
||||
def get_project_root(project_root: Path | None) -> Path:
|
||||
if project_root:
|
||||
return project_root.resolve()
|
||||
# Try git root
|
||||
try:
|
||||
return Path(
|
||||
subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
).resolve()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
# Fallback: script location (<project>/tools/time_tracking/generate_report.py)
|
||||
script_project = Path(__file__).resolve().parents[2]
|
||||
if (script_project / ".git").exists() or (script_project / ".zed-hours").exists():
|
||||
return script_project
|
||||
# Final fallback: cwd
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
def load_jsonl(log_file: Path) -> list[dict]:
|
||||
if not log_file.exists():
|
||||
return []
|
||||
records = []
|
||||
with log_file.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def compute_daily_hours(heartbeats: list[dict], idle_threshold_seconds: int = 300) -> dict:
|
||||
"""Compute active time per day from heartbeats.
|
||||
|
||||
Logic: walk heartbeats in order. While Zed is active, accumulate time up to
|
||||
idle_threshold_seconds between heartbeats. If gap exceeds threshold, treat
|
||||
as idle break.
|
||||
"""
|
||||
by_date = defaultdict(list)
|
||||
for rec in heartbeats:
|
||||
try:
|
||||
dt = parse_iso(rec["timestamp"])
|
||||
except Exception:
|
||||
continue
|
||||
by_date[dt.date()].append({"dt": dt, "active": rec.get("zed_active", False)})
|
||||
|
||||
daily_hours = {}
|
||||
for d, events in by_date.items():
|
||||
events.sort(key=lambda x: x["dt"])
|
||||
total_seconds = 0
|
||||
last_active = None
|
||||
for ev in events:
|
||||
if ev["active"]:
|
||||
if last_active is None:
|
||||
last_active = ev["dt"]
|
||||
else:
|
||||
gap = (ev["dt"] - last_active).total_seconds()
|
||||
if gap <= idle_threshold_seconds:
|
||||
total_seconds += gap
|
||||
# Always advance last_active so long gaps don't accumulate twice
|
||||
last_active = ev["dt"]
|
||||
else:
|
||||
last_active = None
|
||||
daily_hours[d] = total_seconds / 3600
|
||||
return daily_hours
|
||||
|
||||
|
||||
def commits_by_day(commits: list[dict]) -> dict:
|
||||
by_day = defaultdict(list)
|
||||
for c in commits:
|
||||
try:
|
||||
dt = parse_iso(c["timestamp"])
|
||||
except Exception:
|
||||
continue
|
||||
by_day[dt.date()].append(c)
|
||||
return by_day
|
||||
|
||||
|
||||
def generate_report(project_root: Path, from_date: date, to_date: date) -> Path:
|
||||
log_dir = project_root / ".zed-hours"
|
||||
heartbeats = load_jsonl(log_dir / "heartbeats.jsonl")
|
||||
commits = load_jsonl(log_dir / "commits.jsonl")
|
||||
|
||||
# Load config for idle threshold
|
||||
config_file = log_dir / "config.json"
|
||||
idle_threshold = 300
|
||||
if config_file.exists():
|
||||
try:
|
||||
config = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
idle_threshold = int(config.get("idle_threshold_seconds", 300))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
daily_hours = compute_daily_hours(heartbeats, idle_threshold)
|
||||
commits_per_day = commits_by_day(commits)
|
||||
|
||||
report_dir = log_dir / "reports"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path = report_dir / f"{from_date.isoformat()}_to_{to_date.isoformat()}.md"
|
||||
|
||||
lines = []
|
||||
lines.append(f"# Working Hours Report: {from_date} to {to_date}\n")
|
||||
lines.append(f"**Project:** `{project_root}`\n")
|
||||
lines.append(f"**Generated:** {date.today().isoformat()}\n")
|
||||
lines.append(f"**Sources:** `.zed-hours/heartbeats.jsonl`, `.zed-hours/commits.jsonl`\n")
|
||||
lines.append(f"**Idle threshold:** {idle_threshold} seconds\n\n")
|
||||
|
||||
total_hours = 0.0
|
||||
total_commits = 0
|
||||
active_days = 0
|
||||
|
||||
lines.append("## Daily summary\n\n")
|
||||
lines.append("| Date | Heartbeats | Active hours | Commits | First heartbeat | Last heartbeat |\n")
|
||||
lines.append("|---|---:|---:|---:|---|---|\n")
|
||||
|
||||
current = from_date
|
||||
while current <= to_date:
|
||||
events = [hb for hb in heartbeats if parse_iso(hb["timestamp"]).date() == current]
|
||||
day_commits = commits_per_day.get(current, [])
|
||||
hours = daily_hours.get(current, 0.0)
|
||||
|
||||
if events:
|
||||
times = sorted(parse_iso(hb["timestamp"]) for hb in events)
|
||||
first = times[0].strftime("%H:%M:%S")
|
||||
last = times[-1].strftime("%H:%M:%S")
|
||||
active_days += 1
|
||||
else:
|
||||
first = "—"
|
||||
last = "—"
|
||||
|
||||
total_hours += hours
|
||||
total_commits += len(day_commits)
|
||||
|
||||
lines.append(
|
||||
f"| {current.isoformat()} | {len(events)} | {hours:.2f} | {len(day_commits)} | {first} | {last} |\n"
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
|
||||
lines.append("\n")
|
||||
lines.append("## Totals\n\n")
|
||||
lines.append(f"- **Active days:** {active_days}\n")
|
||||
lines.append(f"- **Total active hours (from heartbeats):** {total_hours:.2f}\n")
|
||||
lines.append(f"- **Total commits logged:** {total_commits}\n")
|
||||
lines.append(f"- **Period length:** {(to_date - from_date).days + 1} days\n")
|
||||
lines.append("\n")
|
||||
|
||||
# Commits detail
|
||||
if commits:
|
||||
lines.append("## Commits in this period\n\n")
|
||||
lines.append("| Date | Time | Author | Subject |\n")
|
||||
lines.append("|---|---|---|---|\n")
|
||||
for c in sorted(commits, key=lambda x: parse_iso(x["timestamp"])):
|
||||
try:
|
||||
dt = parse_iso(c["timestamp"])
|
||||
except Exception:
|
||||
continue
|
||||
if from_date <= dt.date() <= to_date:
|
||||
lines.append(
|
||||
f"| {dt.strftime('%Y-%m-%d')} | {dt.strftime('%H:%M:%S')} | {c.get('author', 'unknown')} | {c.get('subject', '')} |\n"
|
||||
)
|
||||
lines.append("\n")
|
||||
|
||||
lines.append("---\n\n")
|
||||
lines.append("*Generated by `tools/time_tracking/generate_report.py`.*\n")
|
||||
|
||||
report_path.write_text("".join(lines), encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
|
||||
def parse_date_arg(arg: str) -> date:
|
||||
if arg.lower() == "today":
|
||||
return date.today()
|
||||
if arg.lower() == "yesterday":
|
||||
return date.today() - timedelta(days=1)
|
||||
return date.fromisoformat(arg)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate working hours report from Zed tracker logs.")
|
||||
parser.add_argument("--project-root", type=Path, default=None, help="Project root directory")
|
||||
parser.add_argument("--from", dest="from_date", type=str, default=None, help="Start date (YYYY-MM-DD, today, yesterday)")
|
||||
parser.add_argument("--to", dest="to_date", type=str, default=None, help="End date (YYYY-MM-DD, today, yesterday)")
|
||||
parser.add_argument("--today", action="store_true", help="Report for today only")
|
||||
parser.add_argument("--yesterday", action="store_true", help="Report for yesterday only")
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = get_project_root(args.project_root)
|
||||
|
||||
if args.today:
|
||||
from_date = to_date = date.today()
|
||||
elif args.yesterday:
|
||||
d = date.today() - timedelta(days=1)
|
||||
from_date = to_date = d
|
||||
else:
|
||||
if not args.from_date or not args.to_date:
|
||||
# Default to last 14 days
|
||||
to_date = date.today()
|
||||
from_date = to_date - timedelta(days=13)
|
||||
else:
|
||||
from_date = parse_date_arg(args.from_date)
|
||||
to_date = parse_date_arg(args.to_date)
|
||||
|
||||
report_path = generate_report(project_root, from_date, to_date)
|
||||
print(f"Report written to: {report_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the Zed Hours Tracker git hook and config for the current project."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Return the git project root, falling back to the script's project directory."""
|
||||
try:
|
||||
root = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
return Path(root).resolve()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
# Fallback 1: directory containing this script's grandparent (<project>/tools/time_tracking/install.py)
|
||||
script_project = Path(__file__).resolve().parents[2]
|
||||
if (script_project / ".git").exists() or (script_project / ".zed-hours").exists():
|
||||
return script_project
|
||||
# Fallback 2: current working directory
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
def ensure_log_dir(project_root: Path) -> Path:
|
||||
log_dir = project_root / ".zed-hours"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
return log_dir
|
||||
|
||||
|
||||
def write_config(log_dir: Path, project_root: Path) -> Path:
|
||||
config = {
|
||||
"project_root": str(project_root),
|
||||
"log_dir": str(log_dir.relative_to(project_root)),
|
||||
"heartbeat_interval_seconds": 60,
|
||||
"idle_threshold_seconds": 300,
|
||||
"max_inactive_gap_seconds": 600,
|
||||
}
|
||||
config_path = log_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Created config: {config_path}")
|
||||
else:
|
||||
print(f"Config already exists: {config_path}")
|
||||
return config_path
|
||||
|
||||
|
||||
def install_git_hook(project_root: Path) -> None:
|
||||
hook_path = project_root / ".git" / "hooks" / "post-commit"
|
||||
if not hook_path.parent.exists():
|
||||
print("No .git directory found; skipping git hook install.")
|
||||
return
|
||||
|
||||
script_dir = project_root / "tools" / "time_tracking"
|
||||
log_commit_script = script_dir / "log_commit.py"
|
||||
|
||||
hook_content = f"""#!/bin/sh
|
||||
# Zed Hours Tracker - post-commit hook
|
||||
python3 "{log_commit_script}" "$@"
|
||||
"""
|
||||
if hook_path.exists():
|
||||
existing = hook_path.read_text(encoding="utf-8")
|
||||
if "Zed Hours Tracker" in existing:
|
||||
print(f"Hook already installed: {hook_path}")
|
||||
return
|
||||
# Append to existing hook
|
||||
hook_path.write_text(existing.rstrip("\n") + "\n\n" + hook_content, encoding="utf-8")
|
||||
else:
|
||||
hook_path.write_text(hook_content, encoding="utf-8")
|
||||
|
||||
# Make executable
|
||||
hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
print(f"Installed git hook: {hook_path}")
|
||||
|
||||
|
||||
def write_systemd_service(project_root: Path, script_dir: Path) -> None:
|
||||
"""Write a sample systemd user service file for background tracking."""
|
||||
systemd_dir = Path.home() / ".config" / "systemd" / "user"
|
||||
service_name = f"zed-hours-{project_root.name}.service"
|
||||
service_path = systemd_dir / service_name
|
||||
|
||||
tracker_script = script_dir / "zed_tracker.py"
|
||||
|
||||
service_content = f"""[Unit]
|
||||
Description=Zed Hours Tracker for {project_root.name}
|
||||
After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory={project_root}
|
||||
ExecStart=/usr/bin/python3 {tracker_script} --project-root {project_root}
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
systemd_dir.mkdir(parents=True, exist_ok=True)
|
||||
service_path.write_text(service_content, encoding="utf-8")
|
||||
print(f"Wrote systemd user service: {service_path}")
|
||||
print(f"Enable with: systemctl --user daemon-reload && systemctl --user enable --now {service_name}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
project_root = get_project_root()
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
|
||||
log_dir = ensure_log_dir(project_root)
|
||||
write_config(log_dir, project_root)
|
||||
install_git_hook(project_root)
|
||||
write_systemd_service(project_root, script_dir)
|
||||
|
||||
print(f"\nZed Hours Tracker installed for project: {project_root}")
|
||||
print(f"Data directory: {log_dir}")
|
||||
print("\nNext steps:")
|
||||
print(" 1. Start the tracker: python3 tools/time_tracking/zed_tracker.py")
|
||||
print(" 2. Or enable the systemd service shown above.")
|
||||
print(" 3. Make a commit to test the git hook.")
|
||||
print(" 4. Generate a report: python3 tools/time_tracking/generate_report.py --today")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Log a git commit event to .zed-hours/commits.jsonl.
|
||||
|
||||
This script is normally called by the git post-commit hook.
|
||||
It can also be run manually for testing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_project_root() -> Path:
|
||||
try:
|
||||
root = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=os.environ.get("GIT_DIR", "."),
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
return Path(root).resolve()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
# Fallback 1: script location (<project>/tools/time_tracking/)
|
||||
script_project = Path(__file__).resolve().parents[2]
|
||||
if (script_project / ".git").exists() or (script_project / ".zed-hours").exists():
|
||||
return script_project
|
||||
# Fallback 2: current directory
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
def get_last_commit_info(project_root: Path) -> dict:
|
||||
try:
|
||||
log_format = "%H|%an|%ae|%s|%D"
|
||||
out = subprocess.check_output(
|
||||
["git", "log", "-1", f"--format={log_format}"],
|
||||
cwd=project_root,
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
sha, author, email, subject, refs = out.split("|", 4)
|
||||
# Try to extract current branch
|
||||
branch = "unknown"
|
||||
try:
|
||||
branch = subprocess.check_output(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_root,
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
return {
|
||||
"sha": sha,
|
||||
"author": author,
|
||||
"email": email,
|
||||
"subject": subject,
|
||||
"branch": branch,
|
||||
}
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {
|
||||
"sha": "unknown",
|
||||
"author": "unknown",
|
||||
"email": "unknown",
|
||||
"subject": f"error: {e}",
|
||||
"branch": "unknown",
|
||||
}
|
||||
|
||||
|
||||
def log_commit(project_root: Path | None = None) -> Path | None:
|
||||
if project_root is None:
|
||||
project_root = get_project_root()
|
||||
|
||||
log_dir = project_root / ".zed-hours"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
commit_info = get_last_commit_info(project_root)
|
||||
record = {
|
||||
"type": "commit",
|
||||
"timestamp": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
"repo": str(project_root),
|
||||
**commit_info,
|
||||
}
|
||||
|
||||
log_file = log_dir / "commits.jsonl"
|
||||
with log_file.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
return log_file
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log_file = log_commit()
|
||||
if log_file:
|
||||
print(f"Logged commit to {log_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Background Zed activity tracker for Wayland (Arch Linux) and fallback.
|
||||
|
||||
Logs heartbeats to .zed-hours/heartbeats.jsonl.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ZED_APP_NAMES = {"zed", "zeditor"}
|
||||
PROJECT_ZED_PATTERN = re.compile(r"(?:^|\s)([^/]+?)(?:\s+[-—]\s+Zed(?:\s+Editor)?)$")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def load_config(project_root: Path) -> dict:
|
||||
config_path = project_root / ".zed-hours" / "config.json"
|
||||
defaults = {
|
||||
"project_root": str(project_root),
|
||||
"log_dir": ".zed-hours",
|
||||
"heartbeat_interval_seconds": 60,
|
||||
"idle_threshold_seconds": 300,
|
||||
"max_inactive_gap_seconds": 600,
|
||||
}
|
||||
if config_path.exists():
|
||||
try:
|
||||
loaded = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
defaults.update(loaded)
|
||||
except Exception as e:
|
||||
print(f"Warning: could not read config: {e}", file=sys.stderr)
|
||||
return defaults
|
||||
|
||||
|
||||
def get_active_window_wayland() -> dict | None:
|
||||
"""Try several Wayland compositor methods to detect the active window."""
|
||||
|
||||
# 1. Hyprland
|
||||
if shutil.which("hyprctl"):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["hyprctl", "activewindow", "-j"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
)
|
||||
data = json.loads(out)
|
||||
title = (data.get("title") or "").strip()
|
||||
class_ = (data.get("class") or "").strip().lower()
|
||||
return {"title": title, "class": class_, "source": "hyprctl"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Sway
|
||||
if shutil.which("swaymsg"):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["swaymsg", "-t", "get_tree"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
)
|
||||
data = json.loads(out)
|
||||
|
||||
def find_focused(node):
|
||||
if node.get("focused"):
|
||||
return node
|
||||
for child in node.get("nodes", []) + node.get("floating_nodes", []):
|
||||
found = find_focused(child)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
focused = find_focused(data)
|
||||
if focused:
|
||||
props = focused.get("window_properties", {})
|
||||
title = focused.get("name") or props.get("title") or ""
|
||||
class_ = props.get("class", "").lower()
|
||||
return {"title": title, "class": class_, "source": "swaymsg"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. GNOME / Mutter via gdbus
|
||||
if shutil.which("gdbus"):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"gdbus", "call", "--session", "--dest", "org.gnome.Shell",
|
||||
"--object-path", "/org/gnome/Shell", "--method", "org.gnome.Shell.Eval",
|
||||
"global.display.focus_window.get_title()",
|
||||
],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
)
|
||||
match = re.search(r"'(.*)'", out)
|
||||
if match:
|
||||
title = match.group(1)
|
||||
return {"title": title, "class": "", "source": "gdbus-gnome"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. KDE / KWin via qdbus / qdbus6
|
||||
for qdbus in ("qdbus6", "qdbus"):
|
||||
if shutil.which(qdbus):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[qdbus, "org.kde.KWin", "/KWin", "org.kde.KWin.activeWindow"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
).strip()
|
||||
# qdbus returns a window id; we would need more introspection for title.
|
||||
# Fallback: try activeWindowCaption if available
|
||||
caption = subprocess.check_output(
|
||||
[qdbus, "org.kde.KWin", "/KWin", "org.kde.KWin.activeWindowCaption"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=2,
|
||||
).strip()
|
||||
if caption:
|
||||
return {"title": caption, "class": "", "source": f"{qdbus}-kwin"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_zed(window: dict | None) -> bool:
|
||||
if not window:
|
||||
return False
|
||||
title = window.get("title", "").lower()
|
||||
class_ = window.get("class", "").lower()
|
||||
return (
|
||||
class_ in ZED_APP_NAMES
|
||||
or "zed" in class_
|
||||
or title.endswith(" - zed")
|
||||
or title.endswith(" - zed editor")
|
||||
or "zeditor" in class_
|
||||
)
|
||||
|
||||
|
||||
def extract_project_from_title(title: str, project_root: Path) -> str | None:
|
||||
"""Try to identify which project/folder is open in Zed from window title."""
|
||||
match = PROJECT_ZED_PATTERN.search(title)
|
||||
if match:
|
||||
return match.group(1)
|
||||
# Fallback: if title ends with the project root name, assume this project
|
||||
if project_root.name.lower() in title.lower():
|
||||
return project_root.name
|
||||
return None
|
||||
|
||||
|
||||
def get_latest_project_file_activity(project_root: Path) -> datetime | None:
|
||||
"""Fallback: find the most recent access or modification time under the project."""
|
||||
latest = None
|
||||
# Common directories to ignore
|
||||
ignored = {".git", ".zed-hours", "__pycache__", "node_modules", ".venv", "venv"}
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(project_root):
|
||||
# Prune ignored dirs
|
||||
dirnames[:] = [d for d in dirnames if d not in ignored]
|
||||
for name in filenames:
|
||||
# Skip hidden, binary-ish, or generated files
|
||||
if name.startswith("."):
|
||||
continue
|
||||
path = Path(dirpath) / name
|
||||
try:
|
||||
stat = path.stat()
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
||||
if latest is None or mtime > latest:
|
||||
latest = mtime
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Warning: filesystem scan failed: {e}", file=sys.stderr)
|
||||
return latest
|
||||
|
||||
|
||||
def log_heartbeat(log_dir: Path, project_root: Path, window: dict | None, active: bool, source: str) -> None:
|
||||
title = window.get("title") if window else None
|
||||
project_in_title = None
|
||||
if title:
|
||||
project_in_title = extract_project_from_title(title, project_root)
|
||||
|
||||
record = {
|
||||
"timestamp": now_iso(),
|
||||
"project": str(project_root),
|
||||
"project_in_title": project_in_title,
|
||||
"zed_active": active,
|
||||
"source": source,
|
||||
"title": title,
|
||||
"class": window.get("class") if window else None,
|
||||
}
|
||||
|
||||
log_file = log_dir / "heartbeats.jsonl"
|
||||
with log_file.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def tracker_loop(project_root: Path, config: dict) -> None:
|
||||
log_dir = project_root / config["log_dir"]
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
interval = int(config.get("heartbeat_interval_seconds", 60))
|
||||
idle_threshold = int(config.get("idle_threshold_seconds", 300))
|
||||
|
||||
last_window_state: dict | None = None
|
||||
last_activity_time = time.time()
|
||||
filesystem_last = None
|
||||
|
||||
print(f"Zed tracker started for {project_root}")
|
||||
print(f"Logging to {log_dir}")
|
||||
print(f"Heartbeat interval: {interval}s")
|
||||
print("Press Ctrl+C to stop.\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.time()
|
||||
window = get_active_window_wayland()
|
||||
active = is_zed(window)
|
||||
source = window.get("source") if window else None
|
||||
|
||||
if active:
|
||||
last_activity_time = now
|
||||
else:
|
||||
# Fallback: check filesystem activity
|
||||
latest = get_latest_project_file_activity(project_root)
|
||||
if latest and (filesystem_last is None or latest > filesystem_last):
|
||||
filesystem_last = latest
|
||||
last_activity_time = now
|
||||
active = True
|
||||
source = "filesystem-fallback"
|
||||
|
||||
log_heartbeat(log_dir, project_root, window, active, source or "unknown")
|
||||
|
||||
if active != (last_window_state is not None and is_zed(last_window_state)):
|
||||
status = "ACTIVE" if active else "inactive"
|
||||
print(f"[{now_iso()}] Zed {status} (source: {source})")
|
||||
|
||||
last_window_state = window
|
||||
time.sleep(interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nZed tracker stopped.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Track Zed activity and log heartbeats.")
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Project root directory (defaults to git root or script location)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = args.project_root
|
||||
if project_root:
|
||||
project_root = project_root.resolve()
|
||||
else:
|
||||
# Try git root
|
||||
try:
|
||||
project_root = Path(
|
||||
subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
).resolve()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
# Fallback: script location
|
||||
if project_root is None:
|
||||
script_project = Path(__file__).resolve().parents[2]
|
||||
if (script_project / ".git").exists() or (script_project / ".zed-hours").exists():
|
||||
project_root = script_project
|
||||
# Final fallback: cwd
|
||||
if project_root is None:
|
||||
project_root = Path.cwd().resolve()
|
||||
|
||||
config = load_config(project_root)
|
||||
tracker_loop(project_root, config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user