capsule-narrative 0.1.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.
- capsule_narrative-0.1.0/PKG-INFO +128 -0
- capsule_narrative-0.1.0/README.md +118 -0
- capsule_narrative-0.1.0/capsule/__init__.py +3 -0
- capsule_narrative-0.1.0/capsule/daemon.py +174 -0
- capsule_narrative-0.1.0/capsule/git_ops.py +160 -0
- capsule_narrative-0.1.0/capsule/narrator.py +173 -0
- capsule_narrative-0.1.0/capsule/store.py +119 -0
- capsule_narrative-0.1.0/capsule/tui.py +204 -0
- capsule_narrative-0.1.0/capsule/watcher.py +123 -0
- capsule_narrative-0.1.0/capsule_narrative.egg-info/PKG-INFO +128 -0
- capsule_narrative-0.1.0/capsule_narrative.egg-info/SOURCES.txt +15 -0
- capsule_narrative-0.1.0/capsule_narrative.egg-info/dependency_links.txt +1 -0
- capsule_narrative-0.1.0/capsule_narrative.egg-info/entry_points.txt +2 -0
- capsule_narrative-0.1.0/capsule_narrative.egg-info/requires.txt +3 -0
- capsule_narrative-0.1.0/capsule_narrative.egg-info/top_level.txt +1 -0
- capsule_narrative-0.1.0/pyproject.toml +17 -0
- capsule_narrative-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capsule-narrative
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A git-like filesystem that automatically creates narrative commits.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: watchdog>=4.0.0
|
|
8
|
+
Requires-Dist: gitpython>=3.1.0
|
|
9
|
+
Requires-Dist: textual>=0.41.0
|
|
10
|
+
|
|
11
|
+
<h1 align="center">timecapsule</h1>
|
|
12
|
+
<p align="center">
|
|
13
|
+
<em>A git-like filesystem that automatically creates narrative commits.</em>
|
|
14
|
+
<br>
|
|
15
|
+
<br>
|
|
16
|
+
<a href="#quick-start">Quick Start</a> ·
|
|
17
|
+
<a href="#how-it-works">How It Works</a> ·
|
|
18
|
+
<a href="#browsing">Browsing</a> ·
|
|
19
|
+
<a href="#ollama">LLM Narrator</a>
|
|
20
|
+
<br>
|
|
21
|
+
<img src="https://img.shields.io/badge/python-≥3.10-00ff88" alt="Python 3.10+">
|
|
22
|
+
<img src="https://img.shields.io/badge/license-MIT-00ff88" alt="MIT License">
|
|
23
|
+
<img src="https://img.shields.io/badge/version-0.1.0-00ff88" alt="Version 0.1.0">
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
Every file has a story. timecapsule is a background daemon that watches your project folders and creates narrative commit messages from your changes — automatically.
|
|
29
|
+
|
|
30
|
+
After 2 minutes of inactivity on a file, it snapshots the changes to a local Git repository. The commit message is generated by a small local LLM that summarizes *why* you made the changes, not just what changed.
|
|
31
|
+
|
|
32
|
+
> *"Renamed parse() to tokenize() after the linter complained about naming — also dropped the unused xml import."*
|
|
33
|
+
|
|
34
|
+
Over time, you get a searchable, browsable timeline of your work. Each file's history becomes a diary.
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install capsule-narrative
|
|
40
|
+
timecapsule
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This watches the current directory tree. After 2 minutes of inactivity on a file, it snapshots the changes.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
timecapsule /path/to/project # Watch a specific directory
|
|
47
|
+
timecapsule --idle 60 # Faster snapshots (60s idle)
|
|
48
|
+
timecapsule --once # One-time snapshot of all files
|
|
49
|
+
timecapsule --browse # Open the timeline browser TUI
|
|
50
|
+
timecapsule --status # Show capsule stats
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How It Works
|
|
54
|
+
|
|
55
|
+
**File watcher.** A background daemon monitors file changes using `watchdog`. When a file hasn't been modified for 2 minutes (configurable), it triggers a snapshot — you've finished a thought.
|
|
56
|
+
|
|
57
|
+
**Git backend.** Each file gets its own orphan branch in a dedicated bare repository at `~/.capsule/timecapsule`. The user's real Git repos are never touched. Branches are named by canonical path, so renaming or moving a file can continue its history.
|
|
58
|
+
|
|
59
|
+
**Narrative commit messages.** The daemon diffs the current file against its last snapshot and generates a commit message that reads like a diary entry.
|
|
60
|
+
|
|
61
|
+
## Browsing
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
timecapsule --browse
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Opens a Textual TUI that shows:
|
|
68
|
+
- **Tracked files** — all files with snapshots, sorted by most recent
|
|
69
|
+
- **Timeline** — commit history for the selected file with narrative messages
|
|
70
|
+
- **Diff preview** — file content at any selected snapshot
|
|
71
|
+
|
|
72
|
+
You can scroll through history, search messages, and see exactly what your file looked like at any point.
|
|
73
|
+
|
|
74
|
+
## Ollama
|
|
75
|
+
|
|
76
|
+
timecapsule works without Ollama — it generates structural commit messages by default ("Added 3 lines to main.py (Python)"). But with Ollama installed, the messages transform into genuine narrative.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ollama pull qwen2.5-coder:1.5b
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This ~1GB model runs on any modern laptop and generates commit messages in ~2 seconds. Smaller models like `llama3.2:1b` also work.
|
|
83
|
+
|
|
84
|
+
The prompt context includes the file diff, the file path, and the previous commit message for continuity. The LLM explains *why* the changes were made, not just *what* changed.
|
|
85
|
+
|
|
86
|
+
## Architecture
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
~/.capsule/
|
|
90
|
+
├── timecapsule/ # Bare git repository
|
|
91
|
+
│ └── refs/heads/ # One orphan branch per file
|
|
92
|
+
│ ├── home-user-project-main-py
|
|
93
|
+
│ ├── home-user-project-readme-md
|
|
94
|
+
│ └── ...
|
|
95
|
+
└── timeline.db # SQLite index for fast queries
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Each snapshot:
|
|
99
|
+
1. Hashes the file content into a git blob
|
|
100
|
+
2. Creates a tree object referencing the blob
|
|
101
|
+
3. Commits the tree to the file's orphan branch (with parent if one exists)
|
|
102
|
+
4. Records metadata in SQLite for fast timeline queries
|
|
103
|
+
|
|
104
|
+
The SQLite store makes `timecapsule --browse` instant — no git log parsing needed.
|
|
105
|
+
|
|
106
|
+
## FAQ
|
|
107
|
+
|
|
108
|
+
**Q: Does it affect my existing Git repos?**
|
|
109
|
+
A: No. timecapsule uses a dedicated bare repository at `~/.capsule/timecapsule`. Your `.git` directories are untouched.
|
|
110
|
+
|
|
111
|
+
**Q: What file types are watched?**
|
|
112
|
+
A: Common source files: `.py`, `.js`, `.ts`, `.rs`, `.go`, `.java`, `.md`, `.json`, `.yaml`, `.css`, `.html`, `.jsx`, `.tsx`, `.c`, `.cpp`, `.h`, `.sh`, `.rb`, `.sql`, and more.
|
|
113
|
+
|
|
114
|
+
**Q: Can I delete the capsule database?**
|
|
115
|
+
A: Yes — remove `~/.capsule/` to wipe everything. Snapshots are disposable by design.
|
|
116
|
+
|
|
117
|
+
**Q: Does it work on Windows/Mac/Linux?**
|
|
118
|
+
A: Yes. The watcher uses `watchdog` which works on all three platforms. The git operations are cross-platform.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
*Every file has a story.*
|
|
127
|
+
|
|
128
|
+
<!-- test edit for timecapsule end-to-end -->
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<h1 align="center">timecapsule</h1>
|
|
2
|
+
<p align="center">
|
|
3
|
+
<em>A git-like filesystem that automatically creates narrative commits.</em>
|
|
4
|
+
<br>
|
|
5
|
+
<br>
|
|
6
|
+
<a href="#quick-start">Quick Start</a> ·
|
|
7
|
+
<a href="#how-it-works">How It Works</a> ·
|
|
8
|
+
<a href="#browsing">Browsing</a> ·
|
|
9
|
+
<a href="#ollama">LLM Narrator</a>
|
|
10
|
+
<br>
|
|
11
|
+
<img src="https://img.shields.io/badge/python-≥3.10-00ff88" alt="Python 3.10+">
|
|
12
|
+
<img src="https://img.shields.io/badge/license-MIT-00ff88" alt="MIT License">
|
|
13
|
+
<img src="https://img.shields.io/badge/version-0.1.0-00ff88" alt="Version 0.1.0">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
Every file has a story. timecapsule is a background daemon that watches your project folders and creates narrative commit messages from your changes — automatically.
|
|
19
|
+
|
|
20
|
+
After 2 minutes of inactivity on a file, it snapshots the changes to a local Git repository. The commit message is generated by a small local LLM that summarizes *why* you made the changes, not just what changed.
|
|
21
|
+
|
|
22
|
+
> *"Renamed parse() to tokenize() after the linter complained about naming — also dropped the unused xml import."*
|
|
23
|
+
|
|
24
|
+
Over time, you get a searchable, browsable timeline of your work. Each file's history becomes a diary.
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install capsule-narrative
|
|
30
|
+
timecapsule
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This watches the current directory tree. After 2 minutes of inactivity on a file, it snapshots the changes.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
timecapsule /path/to/project # Watch a specific directory
|
|
37
|
+
timecapsule --idle 60 # Faster snapshots (60s idle)
|
|
38
|
+
timecapsule --once # One-time snapshot of all files
|
|
39
|
+
timecapsule --browse # Open the timeline browser TUI
|
|
40
|
+
timecapsule --status # Show capsule stats
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## How It Works
|
|
44
|
+
|
|
45
|
+
**File watcher.** A background daemon monitors file changes using `watchdog`. When a file hasn't been modified for 2 minutes (configurable), it triggers a snapshot — you've finished a thought.
|
|
46
|
+
|
|
47
|
+
**Git backend.** Each file gets its own orphan branch in a dedicated bare repository at `~/.capsule/timecapsule`. The user's real Git repos are never touched. Branches are named by canonical path, so renaming or moving a file can continue its history.
|
|
48
|
+
|
|
49
|
+
**Narrative commit messages.** The daemon diffs the current file against its last snapshot and generates a commit message that reads like a diary entry.
|
|
50
|
+
|
|
51
|
+
## Browsing
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
timecapsule --browse
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Opens a Textual TUI that shows:
|
|
58
|
+
- **Tracked files** — all files with snapshots, sorted by most recent
|
|
59
|
+
- **Timeline** — commit history for the selected file with narrative messages
|
|
60
|
+
- **Diff preview** — file content at any selected snapshot
|
|
61
|
+
|
|
62
|
+
You can scroll through history, search messages, and see exactly what your file looked like at any point.
|
|
63
|
+
|
|
64
|
+
## Ollama
|
|
65
|
+
|
|
66
|
+
timecapsule works without Ollama — it generates structural commit messages by default ("Added 3 lines to main.py (Python)"). But with Ollama installed, the messages transform into genuine narrative.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
ollama pull qwen2.5-coder:1.5b
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This ~1GB model runs on any modern laptop and generates commit messages in ~2 seconds. Smaller models like `llama3.2:1b` also work.
|
|
73
|
+
|
|
74
|
+
The prompt context includes the file diff, the file path, and the previous commit message for continuity. The LLM explains *why* the changes were made, not just *what* changed.
|
|
75
|
+
|
|
76
|
+
## Architecture
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
~/.capsule/
|
|
80
|
+
├── timecapsule/ # Bare git repository
|
|
81
|
+
│ └── refs/heads/ # One orphan branch per file
|
|
82
|
+
│ ├── home-user-project-main-py
|
|
83
|
+
│ ├── home-user-project-readme-md
|
|
84
|
+
│ └── ...
|
|
85
|
+
└── timeline.db # SQLite index for fast queries
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Each snapshot:
|
|
89
|
+
1. Hashes the file content into a git blob
|
|
90
|
+
2. Creates a tree object referencing the blob
|
|
91
|
+
3. Commits the tree to the file's orphan branch (with parent if one exists)
|
|
92
|
+
4. Records metadata in SQLite for fast timeline queries
|
|
93
|
+
|
|
94
|
+
The SQLite store makes `timecapsule --browse` instant — no git log parsing needed.
|
|
95
|
+
|
|
96
|
+
## FAQ
|
|
97
|
+
|
|
98
|
+
**Q: Does it affect my existing Git repos?**
|
|
99
|
+
A: No. timecapsule uses a dedicated bare repository at `~/.capsule/timecapsule`. Your `.git` directories are untouched.
|
|
100
|
+
|
|
101
|
+
**Q: What file types are watched?**
|
|
102
|
+
A: Common source files: `.py`, `.js`, `.ts`, `.rs`, `.go`, `.java`, `.md`, `.json`, `.yaml`, `.css`, `.html`, `.jsx`, `.tsx`, `.c`, `.cpp`, `.h`, `.sh`, `.rb`, `.sql`, and more.
|
|
103
|
+
|
|
104
|
+
**Q: Can I delete the capsule database?**
|
|
105
|
+
A: Yes — remove `~/.capsule/` to wipe everything. Snapshots are disposable by design.
|
|
106
|
+
|
|
107
|
+
**Q: Does it work on Windows/Mac/Linux?**
|
|
108
|
+
A: Yes. The watcher uses `watchdog` which works on all three platforms. The git operations are cross-platform.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
*Every file has a story.*
|
|
117
|
+
|
|
118
|
+
<!-- test edit for timecapsule end-to-end -->
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""timecapsule daemon — entry point that wires watcher, git, store, and narrator.
|
|
2
|
+
|
|
3
|
+
Watches a directory tree for file changes. After 2 minutes of inactivity
|
|
4
|
+
on a file, snapshots it to the timecapsule repo with a narrative commit
|
|
5
|
+
message (generated by Ollama if available, otherwise structural)."""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import git_ops
|
|
15
|
+
from .store import TimelineStore
|
|
16
|
+
from .watcher import FileWatcher, IDLE_SECONDS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _snapshot_callback(filepath: str):
|
|
20
|
+
"""Called when a file has been idle long enough to snapshot."""
|
|
21
|
+
from .narrator import generate_message as gen_msg
|
|
22
|
+
|
|
23
|
+
store = TimelineStore()
|
|
24
|
+
repo = git_ops.ensure_repo()
|
|
25
|
+
|
|
26
|
+
# Get current content
|
|
27
|
+
if not os.path.isfile(filepath):
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
with open(filepath, "rb") as f:
|
|
31
|
+
current_content = f.read().decode("utf-8", errors="replace")
|
|
32
|
+
|
|
33
|
+
# Get previous snapshot content and message for context
|
|
34
|
+
branch = git_ops._branch_name(filepath)
|
|
35
|
+
previous_content = None
|
|
36
|
+
previous_message = None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
commits = list(repo.iter_commits(branch, max_count=1))
|
|
40
|
+
if commits:
|
|
41
|
+
previous_message = commits[0].message.strip()
|
|
42
|
+
rel_path = os.path.basename(filepath)
|
|
43
|
+
try:
|
|
44
|
+
previous_content = repo.git.show(f"{commits[0].hexsha}:{rel_path}")
|
|
45
|
+
except Exception:
|
|
46
|
+
previous_content = None
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
# Generate narrative message
|
|
51
|
+
message = gen_msg(
|
|
52
|
+
filepath=filepath,
|
|
53
|
+
current_content=current_content,
|
|
54
|
+
previous_content=previous_content,
|
|
55
|
+
previous_message=previous_message,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Snapshot to git
|
|
59
|
+
hexsha = git_ops.snapshot_file(filepath, message, repo)
|
|
60
|
+
if hexsha:
|
|
61
|
+
store.record_snapshot(filepath, hexsha, message, branch)
|
|
62
|
+
print(f"[snapshot] {os.path.basename(filepath)} › {message[:60]}...", flush=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def watch(paths: list[str], idle: int = IDLE_SECONDS, foreground: bool = True, once: bool = False):
|
|
66
|
+
"""Start the file watcher and run until interrupted."""
|
|
67
|
+
print(f"🕰️ timecapsule watching: {', '.join(paths)}")
|
|
68
|
+
print(f" idle threshold: {idle}s | snapshots: ~/.capsule/timecapsule")
|
|
69
|
+
print(f" press Ctrl+C to stop\n")
|
|
70
|
+
|
|
71
|
+
watcher = FileWatcher(paths, _snapshot_callback, idle_seconds=idle)
|
|
72
|
+
|
|
73
|
+
def _handle_signal(sig, frame):
|
|
74
|
+
watcher.stop()
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
78
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
79
|
+
|
|
80
|
+
watcher.start()
|
|
81
|
+
print(" ✓ watcher started")
|
|
82
|
+
|
|
83
|
+
# If --once, do one pass of all files and exit
|
|
84
|
+
if once:
|
|
85
|
+
_scan_all_files(paths)
|
|
86
|
+
watcher.stop()
|
|
87
|
+
print("\n ✓ one-time scan complete")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
while True:
|
|
92
|
+
time.sleep(1)
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
pass
|
|
95
|
+
finally:
|
|
96
|
+
watcher.stop()
|
|
97
|
+
print("\n ✗ watcher stopped")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _scan_all_files(paths: list[str]):
|
|
101
|
+
"""Immediately snapshot all tracked files (one-time mode)."""
|
|
102
|
+
from .narrator import generate_message as gen_msg
|
|
103
|
+
|
|
104
|
+
store = TimelineStore()
|
|
105
|
+
repo = git_ops.ensure_repo()
|
|
106
|
+
|
|
107
|
+
ext_filter = {
|
|
108
|
+
".py", ".js", ".ts", ".rs", ".go", ".md",
|
|
109
|
+
".txt", ".json", ".yaml", ".toml", ".css",
|
|
110
|
+
".html", ".jsx", ".tsx", ".java", ".c", ".cpp",
|
|
111
|
+
".h", ".sh", ".ps1", ".bat", ".sql", ".rb",
|
|
112
|
+
".php", ".swift", ".kt", ".scala", ".zig",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for root_path in paths:
|
|
116
|
+
for dirpath, _, filenames in os.walk(root_path):
|
|
117
|
+
for fn in filenames:
|
|
118
|
+
if fn.startswith(".") or fn.startswith("__"):
|
|
119
|
+
continue
|
|
120
|
+
ext = os.path.splitext(fn)[1].lower()
|
|
121
|
+
if ext in ext_filter:
|
|
122
|
+
fp = os.path.join(dirpath, fn)
|
|
123
|
+
_snapshot_callback(fp)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main():
|
|
127
|
+
parser = argparse.ArgumentParser(
|
|
128
|
+
description="🕰️ timecapsule — narrative snapshots of your work",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"paths", nargs="*", default=["."],
|
|
132
|
+
help="Directories to watch (default: current directory)",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--idle", type=int, default=IDLE_SECONDS,
|
|
136
|
+
help=f"Idle seconds before snapshot (default: {IDLE_SECONDS})",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--once", action="store_true",
|
|
140
|
+
help="Snapshot all files once and exit (no daemon)",
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--browse", action="store_true",
|
|
144
|
+
help="Open the timeline browser TUI",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--status", action="store_true",
|
|
148
|
+
help="Show capsule stats and exit",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
args = parser.parse_args()
|
|
152
|
+
|
|
153
|
+
if args.browse:
|
|
154
|
+
from .tui import run_browser
|
|
155
|
+
run_browser()
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if args.status:
|
|
159
|
+
store = TimelineStore()
|
|
160
|
+
repo = git_ops.ensure_repo()
|
|
161
|
+
count = store.total_snapshots()
|
|
162
|
+
files = len(store.get_all_files())
|
|
163
|
+
repo_path = str(git_ops.REPO_PATH)
|
|
164
|
+
print(f"🕰️ timecapsule status")
|
|
165
|
+
print(f" snapshots: {count}")
|
|
166
|
+
print(f" tracked files: {files}")
|
|
167
|
+
print(f" repository: {repo_path}")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
watch(args.paths, idle=args.idle, once=args.once)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Git operations for timecapsule.
|
|
2
|
+
|
|
3
|
+
Manages a dedicated hidden repository at ~/.capsule/timecapsule.git.
|
|
4
|
+
Each tracked file gets its own orphan branch named after its canonical path.
|
|
5
|
+
This keeps the user's real .git untouched and makes cleanup trivial.
|
|
6
|
+
|
|
7
|
+
Uses git command-line calls for low-level operations since gitpython's
|
|
8
|
+
LooseObjectDB doesn't handle bare repo blob creation well across versions."""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import git
|
|
18
|
+
|
|
19
|
+
CAPSULE_DIR = Path.home() / ".capsule"
|
|
20
|
+
REPO_PATH = CAPSULE_DIR / "timecapsule"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _branch_name(filepath: str) -> str:
|
|
24
|
+
"""Derive a branch name from a file path.
|
|
25
|
+
|
|
26
|
+
Converts a path like:
|
|
27
|
+
C:/Users/ngugi/project/main.py
|
|
28
|
+
to:
|
|
29
|
+
home-ngugi-project-main-py (safe for git refs).
|
|
30
|
+
"""
|
|
31
|
+
abs_path = os.path.normpath(os.path.abspath(filepath))
|
|
32
|
+
parts = []
|
|
33
|
+
for p in Path(abs_path).parts:
|
|
34
|
+
if ":" in p:
|
|
35
|
+
continue
|
|
36
|
+
sanitized = re.sub(r"[^\w-]", "-", p.lower().strip("-"))
|
|
37
|
+
if sanitized:
|
|
38
|
+
parts.append(sanitized)
|
|
39
|
+
name = "-".join(parts)
|
|
40
|
+
return name[:250]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _git(*args: str, env: Optional[dict] = None, _input: Optional[str] = None) -> str:
|
|
44
|
+
"""Run a git command in the timecapsule repo."""
|
|
45
|
+
cmd = ["git", "--git-dir", str(REPO_PATH)] + list(args)
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
cmd,
|
|
48
|
+
capture_output=True, text=True, input=_input,
|
|
49
|
+
env={**os.environ, **(env or {})},
|
|
50
|
+
)
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()}")
|
|
53
|
+
return result.stdout.strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_repo() -> git.Repo:
|
|
57
|
+
"""Create and return the timecapsule bare repository."""
|
|
58
|
+
CAPSULE_DIR.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
if not (REPO_PATH / "HEAD").exists():
|
|
60
|
+
repo = git.Repo.init(str(REPO_PATH), bare=True)
|
|
61
|
+
else:
|
|
62
|
+
repo = git.Repo(str(REPO_PATH))
|
|
63
|
+
return repo
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def snapshot_file(filepath: str, message: str, repo: git.Repo) -> Optional[str]:
|
|
67
|
+
"""Snapshot a file's current state into its orphan branch.
|
|
68
|
+
|
|
69
|
+
Creates a tree object from the file and commits it as a new root
|
|
70
|
+
commit on the file's orphan branch. If the branch already exists,
|
|
71
|
+
the commit becomes a child of the previous tip.
|
|
72
|
+
|
|
73
|
+
Returns the commit hexsha or None on failure.
|
|
74
|
+
"""
|
|
75
|
+
abs_path = os.path.normpath(os.path.abspath(filepath))
|
|
76
|
+
if not os.path.isfile(abs_path):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
branch = _branch_name(abs_path)
|
|
80
|
+
rel_path = os.path.basename(abs_path)
|
|
81
|
+
|
|
82
|
+
# Create blob via hash-object
|
|
83
|
+
blob_hash = _git("hash-object", "-w", abs_path)
|
|
84
|
+
|
|
85
|
+
# Create tree with the blob
|
|
86
|
+
# git mktree expects: mode SP type SP hash TAB path
|
|
87
|
+
tree_input = f"100644 blob {blob_hash}\t{rel_path}\n"
|
|
88
|
+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
|
|
89
|
+
f.write(tree_input)
|
|
90
|
+
tree_path = f.name
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
tree_hash = _git("mktree", "--missing", _input=open(tree_path, "r").read())
|
|
94
|
+
finally:
|
|
95
|
+
os.unlink(tree_path)
|
|
96
|
+
|
|
97
|
+
# Get parent commit if branch exists
|
|
98
|
+
parent = None
|
|
99
|
+
try:
|
|
100
|
+
parent = _git("rev-parse", f"refs/heads/{branch}")
|
|
101
|
+
except RuntimeError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# Create commit
|
|
105
|
+
env = {
|
|
106
|
+
"GIT_AUTHOR_NAME": "timecapsule",
|
|
107
|
+
"GIT_AUTHOR_EMAIL": "capsule@localhost",
|
|
108
|
+
"GIT_COMMITTER_NAME": "timecapsule",
|
|
109
|
+
"GIT_COMMITTER_EMAIL": "capsule@localhost",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
commit_args = ["commit-tree", tree_hash, "-m", message]
|
|
113
|
+
if parent:
|
|
114
|
+
commit_args.extend(["-p", parent])
|
|
115
|
+
|
|
116
|
+
commit_hash = _git(*commit_args, env=env)
|
|
117
|
+
|
|
118
|
+
# Update branch ref
|
|
119
|
+
_git("update-ref", f"refs/heads/{branch}", commit_hash)
|
|
120
|
+
|
|
121
|
+
return commit_hash
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_timeline(filepath: str, repo: git.Repo, max_count: int = 50) -> list[dict]:
|
|
125
|
+
"""Get the commit history for a file's branch.
|
|
126
|
+
|
|
127
|
+
Returns list of {hexsha, message, committed_datetime, author}.
|
|
128
|
+
"""
|
|
129
|
+
branch = _branch_name(filepath)
|
|
130
|
+
try:
|
|
131
|
+
commits = list(repo.iter_commits(branch, max_count=max_count))
|
|
132
|
+
except (ValueError, git.BadName):
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
results = []
|
|
136
|
+
for c in commits:
|
|
137
|
+
results.append({
|
|
138
|
+
"hexsha": c.hexsha,
|
|
139
|
+
"message": c.message.strip(),
|
|
140
|
+
"committed_datetime": c.committed_datetime.isoformat(),
|
|
141
|
+
"committed_timestamp": c.committed_datetime.timestamp(),
|
|
142
|
+
"author": str(c.author),
|
|
143
|
+
})
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_file_at_commit(filepath: str, commit_hexsha: str, repo: git.Repo) -> Optional[str]:
|
|
148
|
+
"""Retrieve file contents at a given commit."""
|
|
149
|
+
branch = _branch_name(filepath)
|
|
150
|
+
rel_path = os.path.basename(filepath)
|
|
151
|
+
try:
|
|
152
|
+
return repo.git.show(f"{commit_hexsha}:{rel_path}")
|
|
153
|
+
except git.GitCommandError:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def list_tracked_files(repo: git.Repo) -> list[str]:
|
|
158
|
+
"""List all files that have been snapshot (by their branch names)."""
|
|
159
|
+
refs = repo.git.for_each_ref("refs/heads/", format="%(refname:short)").splitlines()
|
|
160
|
+
return [r for r in refs if r.strip()]
|