docketeer-git 0.0.6__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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ .envrc.private
3
+ .claude/settings.local.json
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ workspace/
10
+ .coverage
11
+ .loq_cache
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: docketeer-git
3
+ Version: 0.0.6
4
+ Summary: Git-backed workspace backup plugin for Docketeer
5
+ Project-URL: Homepage, https://github.com/chrisguidry/docketeer
6
+ Project-URL: Repository, https://github.com/chrisguidry/docketeer
7
+ Project-URL: Issues, https://github.com/chrisguidry/docketeer/issues
8
+ Author-email: Chris Guidry <guid@omg.lol>
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Topic :: System :: Archiving :: Backup
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: docketeer
17
+ Requires-Dist: pydocket
18
+ Description-Content-Type: text/markdown
19
+
20
+ # docketeer-git
21
+
22
+ Git-backed workspace backup plugin for
23
+ [Docketeer](https://pypi.org/project/docketeer/). Automatically commits the
24
+ agent's workspace (`~/.docketeer/memory/`) to a local git repo on a timer, and
25
+ optionally pushes to a remote for off-machine backup.
26
+
27
+ Install `docketeer-git` alongside `docketeer` and backups start automatically.
28
+ No agent-facing tools are added — the agent doesn't know about backups.
29
+
30
+ ## Configuration
31
+
32
+ | Variable | Default | Description |
33
+ |---|---|---|
34
+ | `DOCKETEER_GIT_BACKUP_INTERVAL` | `PT5M` | How often to check for changes (ISO 8601 duration or seconds) |
35
+ | `DOCKETEER_GIT_REMOTE` | _(empty)_ | Remote URL to push to. No push if unset. |
36
+ | `DOCKETEER_GIT_BRANCH` | `main` | Branch name to use |
37
+ | `DOCKETEER_GIT_AUTHOR_NAME` | `Docketeer` | Git author name for backup commits |
38
+ | `DOCKETEER_GIT_AUTHOR_EMAIL` | `docketeer@localhost` | Git author email for backup commits |
39
+
40
+ ## How it works
41
+
42
+ A periodic docket task checks the workspace for uncommitted changes every 5
43
+ minutes (configurable). If anything changed, it stages everything and commits
44
+ with a timestamped message. If `DOCKETEER_GIT_REMOTE` is set, it pushes after
45
+ each commit. Push failures are logged but don't crash the agent.
46
+
47
+ The git repo is initialized automatically on first run. You can browse the
48
+ history with standard git tools (`git log`, `git diff`, etc.) in the workspace
49
+ directory.
@@ -0,0 +1,30 @@
1
+ # docketeer-git
2
+
3
+ Git-backed workspace backup plugin for
4
+ [Docketeer](https://pypi.org/project/docketeer/). Automatically commits the
5
+ agent's workspace (`~/.docketeer/memory/`) to a local git repo on a timer, and
6
+ optionally pushes to a remote for off-machine backup.
7
+
8
+ Install `docketeer-git` alongside `docketeer` and backups start automatically.
9
+ No agent-facing tools are added — the agent doesn't know about backups.
10
+
11
+ ## Configuration
12
+
13
+ | Variable | Default | Description |
14
+ |---|---|---|
15
+ | `DOCKETEER_GIT_BACKUP_INTERVAL` | `PT5M` | How often to check for changes (ISO 8601 duration or seconds) |
16
+ | `DOCKETEER_GIT_REMOTE` | _(empty)_ | Remote URL to push to. No push if unset. |
17
+ | `DOCKETEER_GIT_BRANCH` | `main` | Branch name to use |
18
+ | `DOCKETEER_GIT_AUTHOR_NAME` | `Docketeer` | Git author name for backup commits |
19
+ | `DOCKETEER_GIT_AUTHOR_EMAIL` | `docketeer@localhost` | Git author email for backup commits |
20
+
21
+ ## How it works
22
+
23
+ A periodic docket task checks the workspace for uncommitted changes every 5
24
+ minutes (configurable). If anything changed, it stages everything and commits
25
+ with a timestamped message. If `DOCKETEER_GIT_REMOTE` is set, it pushes after
26
+ each commit. Push failures are logged but don't crash the agent.
27
+
28
+ The git repo is initialized automatically on first run. You can browse the
29
+ history with standard git tools (`git log`, `git diff`, etc.) in the workspace
30
+ directory.
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "docketeer-git"
3
+ dynamic = ["version"]
4
+ description = "Git-backed workspace backup plugin for Docketeer"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Chris Guidry", email = "guid@omg.lol" }
8
+ ]
9
+ license = "MIT"
10
+ requires-python = ">=3.12"
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Topic :: System :: Archiving :: Backup",
16
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
17
+ ]
18
+ dependencies = [
19
+ "docketeer",
20
+ "pydocket",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/chrisguidry/docketeer"
25
+ Repository = "https://github.com/chrisguidry/docketeer"
26
+ Issues = "https://github.com/chrisguidry/docketeer/issues"
27
+
28
+ [project.entry-points."docketeer.tasks"]
29
+ git = "docketeer_git:task_collections"
30
+
31
+ [build-system]
32
+ requires = ["hatchling", "hatch-vcs"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.version]
36
+ source = "vcs"
37
+ raw-options.root = ".."
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/docketeer_git"]
41
+
42
+ [tool.uv.sources]
43
+ docketeer = { workspace = true }
44
+
45
+ [tool.pytest_env]
46
+ DOCKETEER_ANTHROPIC_API_KEY = "test-key"
47
+ DOCKETEER_DATA_DIR = "{tmp_path}"
48
+
49
+ [tool.pytest.ini_options]
50
+ minversion = "9.0"
51
+ addopts = [
52
+ "--import-mode=importlib",
53
+ "--cov=docketeer_git",
54
+ "--cov-branch",
55
+ "--cov-report=term-missing",
56
+ "--cov-fail-under=100",
57
+ ]
58
+ asyncio_mode = "auto"
59
+ filterwarnings = [
60
+ "error",
61
+ ]
@@ -0,0 +1,7 @@
1
+ from docketeer_git.backup import backup
2
+
3
+ git_tasks = [backup]
4
+
5
+ task_collections = ["docketeer_git:git_tasks"]
6
+
7
+ __all__ = ["git_tasks", "task_collections"]
@@ -0,0 +1,121 @@
1
+ """Git-backed workspace backup — periodic commit and optional push."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+
9
+ from docket.dependencies import Perpetual
10
+
11
+ from docketeer import environment
12
+ from docketeer.dependencies import EnvironmentStr, WorkspacePath
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ BACKUP_INTERVAL = environment.get_timedelta("GIT_BACKUP_INTERVAL", timedelta(minutes=5))
17
+
18
+ DEFAULT_GITIGNORE = """\
19
+ *.lock
20
+ *.tmp
21
+ *.swp
22
+ *~
23
+ __pycache__/
24
+ tmp/
25
+ """
26
+
27
+
28
+ async def _git(
29
+ cwd: Path,
30
+ *args: str,
31
+ env: dict[str, str] | None = None,
32
+ ) -> asyncio.subprocess.Process:
33
+ """Run a git command and return the completed process."""
34
+ proc = await asyncio.create_subprocess_exec(
35
+ "git",
36
+ *args,
37
+ cwd=cwd,
38
+ stdout=asyncio.subprocess.PIPE,
39
+ stderr=asyncio.subprocess.PIPE,
40
+ env={**os.environ, **env} if env else None,
41
+ )
42
+ await proc.wait()
43
+ return proc
44
+
45
+
46
+ async def _git_config(cwd: Path, key: str, value: str) -> None:
47
+ await _git(cwd, "config", key, value)
48
+
49
+
50
+ async def _init_repo(
51
+ workspace: Path,
52
+ *,
53
+ branch: str,
54
+ remote: str,
55
+ author_name: str,
56
+ author_email: str,
57
+ ) -> None:
58
+ """Initialize a git repo in the workspace if one doesn't exist."""
59
+ await _git(workspace, "init", "-b", branch)
60
+ await _git_config(workspace, "user.name", author_name)
61
+ await _git_config(workspace, "user.email", author_email)
62
+
63
+ if remote:
64
+ await _git(workspace, "remote", "add", "origin", remote)
65
+
66
+ gitignore = workspace / ".gitignore"
67
+ if not gitignore.exists():
68
+ gitignore.write_text(DEFAULT_GITIGNORE)
69
+
70
+
71
+ async def _has_changes(workspace: Path) -> bool:
72
+ """Check if the workspace has any uncommitted changes or untracked files."""
73
+ status = await _git(workspace, "status", "--porcelain")
74
+ assert status.stdout is not None
75
+ output = await status.stdout.read()
76
+ return bool(output.strip())
77
+
78
+
79
+ async def backup(
80
+ perpetual: Perpetual = Perpetual(every=BACKUP_INTERVAL, automatic=True),
81
+ workspace: Path = WorkspacePath(),
82
+ remote: str = EnvironmentStr("GIT_REMOTE", ""),
83
+ branch: str = EnvironmentStr("GIT_BRANCH", "main"),
84
+ author_name: str = EnvironmentStr("GIT_AUTHOR_NAME", "Docketeer"),
85
+ author_email: str = EnvironmentStr("GIT_AUTHOR_EMAIL", "docketeer@localhost"),
86
+ ) -> None:
87
+ """Commit workspace changes and optionally push to a remote."""
88
+ if not any(workspace.iterdir()):
89
+ return
90
+
91
+ if not (workspace / ".git").is_dir():
92
+ await _init_repo(
93
+ workspace,
94
+ branch=branch,
95
+ remote=remote,
96
+ author_name=author_name,
97
+ author_email=author_email,
98
+ )
99
+
100
+ if not await _has_changes(workspace):
101
+ return
102
+
103
+ await _git(workspace, "add", ".")
104
+
105
+ author_env = {
106
+ "GIT_AUTHOR_NAME": author_name,
107
+ "GIT_AUTHOR_EMAIL": author_email,
108
+ "GIT_COMMITTER_NAME": author_name,
109
+ "GIT_COMMITTER_EMAIL": author_email,
110
+ }
111
+
112
+ timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
113
+ await _git(workspace, "commit", "-m", f"backup: {timestamp}", env=author_env)
114
+ log.info("Workspace backup committed: %s", timestamp)
115
+
116
+ if remote:
117
+ result = await _git(workspace, "push", "-u", "origin", branch)
118
+ if result.returncode != 0:
119
+ assert result.stderr is not None
120
+ stderr = (await result.stderr.read()).decode()
121
+ log.warning("Push failed: %s", stderr)
File without changes
@@ -0,0 +1,203 @@
1
+ """Tests for the git-backed workspace backup task."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from docketeer_git.backup import _git, _git_config, backup
9
+
10
+
11
+ @pytest.fixture()
12
+ def workspace(tmp_path: Path) -> Path:
13
+ ws = tmp_path / "memory"
14
+ ws.mkdir()
15
+ return ws
16
+
17
+
18
+ def git_log(workspace: Path) -> list[str]:
19
+ result = subprocess.run(
20
+ ["git", "log", "--oneline", "--format=%s"],
21
+ cwd=workspace,
22
+ capture_output=True,
23
+ text=True,
24
+ check=True,
25
+ )
26
+ return result.stdout.strip().splitlines()
27
+
28
+
29
+ def git_branch(workspace: Path) -> str:
30
+ result = subprocess.run(
31
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
32
+ cwd=workspace,
33
+ capture_output=True,
34
+ text=True,
35
+ check=True,
36
+ )
37
+ return result.stdout.strip()
38
+
39
+
40
+ async def _backup(
41
+ workspace: Path,
42
+ *,
43
+ remote: str = "",
44
+ branch: str = "main",
45
+ author_name: str = "Test",
46
+ author_email: str = "test@test",
47
+ ) -> None:
48
+ """Call backup() with all dependency parameters resolved for tests."""
49
+ await backup(
50
+ workspace=workspace,
51
+ remote=remote,
52
+ branch=branch,
53
+ author_name=author_name,
54
+ author_email=author_email,
55
+ )
56
+
57
+
58
+ async def test_initializes_repo(workspace: Path):
59
+ (workspace / "test.txt").write_text("hello")
60
+ await _backup(workspace)
61
+ assert (workspace / ".git").is_dir()
62
+
63
+
64
+ async def test_uses_configured_branch(workspace: Path):
65
+ (workspace / "test.txt").write_text("hello")
66
+ await _backup(workspace, branch="backups")
67
+ assert git_branch(workspace) == "backups"
68
+
69
+
70
+ async def test_commits_changes(workspace: Path):
71
+ (workspace / "note.md").write_text("first")
72
+ await _backup(workspace)
73
+ commits = git_log(workspace)
74
+ assert len(commits) == 1
75
+ assert commits[0].startswith("backup: ")
76
+
77
+
78
+ async def test_clean_workspace_no_commit(workspace: Path):
79
+ (workspace / "note.md").write_text("first")
80
+ await _backup(workspace)
81
+ await _backup(workspace)
82
+ commits = git_log(workspace)
83
+ assert len(commits) == 1
84
+
85
+
86
+ async def test_multiple_changes(workspace: Path):
87
+ (workspace / "a.txt").write_text("one")
88
+ await _backup(workspace)
89
+ (workspace / "b.txt").write_text("two")
90
+ await _backup(workspace)
91
+ commits = git_log(workspace)
92
+ assert len(commits) == 2
93
+
94
+
95
+ async def test_pushes_when_remote_configured(workspace: Path):
96
+ remote = workspace.parent / "remote.git"
97
+ subprocess.run(
98
+ ["git", "init", "--bare", "-b", "main", str(remote)],
99
+ check=True,
100
+ capture_output=True,
101
+ )
102
+
103
+ (workspace / "note.md").write_text("push me")
104
+ await _backup(workspace, remote=str(remote))
105
+
106
+ result = subprocess.run(
107
+ ["git", "log", "--oneline", "--format=%s"],
108
+ cwd=remote,
109
+ capture_output=True,
110
+ text=True,
111
+ check=True,
112
+ )
113
+ assert "backup: " in result.stdout
114
+
115
+
116
+ async def test_no_push_without_remote(workspace: Path):
117
+ (workspace / "note.md").write_text("no push")
118
+ await _backup(workspace)
119
+ assert (workspace / ".git").is_dir()
120
+ result = subprocess.run(
121
+ ["git", "remote"],
122
+ cwd=workspace,
123
+ capture_output=True,
124
+ text=True,
125
+ check=True,
126
+ )
127
+ assert result.stdout.strip() == ""
128
+
129
+
130
+ async def test_push_failure_logged_not_raised(
131
+ workspace: Path, caplog: pytest.LogCaptureFixture
132
+ ):
133
+ (workspace / "note.md").write_text("will fail push")
134
+ await _backup(workspace, remote="https://invalid.example.com/nope.git")
135
+
136
+ assert any("push failed" in r.message.lower() for r in caplog.records)
137
+
138
+
139
+ async def test_gitignore_created(workspace: Path):
140
+ (workspace / "note.md").write_text("hello")
141
+ await _backup(workspace)
142
+ gitignore = workspace / ".gitignore"
143
+ assert gitignore.exists()
144
+ content = gitignore.read_text()
145
+ assert "*.lock" in content
146
+ assert "*.tmp" in content
147
+
148
+
149
+ async def test_gitignore_not_overwritten(workspace: Path):
150
+ gitignore = workspace / ".gitignore"
151
+ gitignore.write_text("custom\n")
152
+ (workspace / "note.md").write_text("hello")
153
+ await _backup(workspace)
154
+ assert gitignore.read_text() == "custom\n"
155
+
156
+
157
+ async def test_sets_author_config(workspace: Path):
158
+ (workspace / "note.md").write_text("hello")
159
+ await _backup(workspace, author_name="Nix", author_email="nix@example.com")
160
+
161
+ result = subprocess.run(
162
+ ["git", "log", "--format=%an <%ae>", "-1"],
163
+ cwd=workspace,
164
+ capture_output=True,
165
+ text=True,
166
+ check=True,
167
+ )
168
+ assert result.stdout.strip() == "Nix <nix@example.com>"
169
+
170
+
171
+ async def test_git_helper(tmp_path: Path):
172
+ result = await _git(tmp_path, "init")
173
+ assert result.returncode == 0
174
+
175
+
176
+ async def test_git_config_helper(tmp_path: Path):
177
+ await _git(tmp_path, "init")
178
+ await _git_config(tmp_path, "user.name", "test")
179
+ result = subprocess.run(
180
+ ["git", "config", "user.name"],
181
+ cwd=tmp_path,
182
+ capture_output=True,
183
+ text=True,
184
+ check=True,
185
+ )
186
+ assert result.stdout.strip() == "test"
187
+
188
+
189
+ def test_git_tasks_exported():
190
+ from docketeer_git import git_tasks
191
+
192
+ assert backup in git_tasks
193
+
194
+
195
+ def test_task_collections_exported():
196
+ from docketeer_git import task_collections
197
+
198
+ assert "docketeer_git:git_tasks" in task_collections
199
+
200
+
201
+ async def test_empty_workspace_no_commit(workspace: Path):
202
+ await _backup(workspace)
203
+ assert not (workspace / ".git").is_dir()