worklog-for-claude-code 0.3.1__tar.gz → 0.3.2__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.
- {worklog_for_claude_code-0.3.1/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.3.2}/PKG-INFO +6 -2
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/README.md +5 -1
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/pyproject.toml +1 -1
- worklog_for_claude_code-0.3.2/tests/test_syncstatus.py +208 -0
- worklog_for_claude_code-0.3.2/tests/test_updates.py +211 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/__init__.py +1 -1
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/store.py +16 -4
- worklog_for_claude_code-0.3.2/worklog/syncstatus.py +298 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/updates.py +57 -8
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/web.py +227 -22
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2/worklog_for_claude_code.egg-info}/PKG-INFO +6 -2
- worklog_for_claude_code-0.3.1/tests/test_syncstatus.py +0 -116
- worklog_for_claude_code-0.3.1/tests/test_updates.py +0 -112
- worklog_for_claude_code-0.3.1/worklog/syncstatus.py +0 -148
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/LICENSE +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/setup.cfg +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_accounts.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_chats.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_checks.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_demo.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_forget.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_platforms.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_viz.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_watcher.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_worklog.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/chats.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/checks.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/cli.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/collector.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/config.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/demo.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/forget.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/hooks.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/identity.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/locks.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/parser.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/redact.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/report.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/services.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/viz.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/watcher.py +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/SOURCES.txt +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/dependency_links.txt +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/entry_points.txt +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/requires.txt +0 -0
- {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: worklog-for-claude-code
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: See what you did with Claude Code each day and how many tokens it used: an automatic daily work log across accounts and devices, private by default
|
|
5
5
|
Author: Urvin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -113,7 +113,11 @@ A checklist then shows anything still missing, with the exact command for your c
|
|
|
113
113
|
|
|
114
114
|
**Several accounts or computers?** Settings walks you through it. For several computers, see [Full setup](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup).
|
|
115
115
|
|
|
116
|
-
**
|
|
116
|
+
**Using several GitHub accounts?** GitHub knows you by your SSH key. In Settings → Private data repo, click **Test connection** to see which account signs in, and open *Several GitHub accounts on this computer?* for the steps. [More →](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup)
|
|
117
|
+
|
|
118
|
+
**Uploads to my data repo fail?** Click **Test connection** in Settings: it says what's wrong in plain words. See [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md#uploads-fail-or-the-wrong-github-account-is-used).
|
|
119
|
+
|
|
120
|
+
**How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade --pip-args=--no-cache-dir worklog-for-claude-code`.
|
|
117
121
|
|
|
118
122
|
**Something isn't working?** Run `claude-worklog doctor`, or see [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md).
|
|
119
123
|
|
|
@@ -74,7 +74,11 @@ A checklist then shows anything still missing, with the exact command for your c
|
|
|
74
74
|
|
|
75
75
|
**Several accounts or computers?** Settings walks you through it. For several computers, see [Full setup](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup).
|
|
76
76
|
|
|
77
|
-
**
|
|
77
|
+
**Using several GitHub accounts?** GitHub knows you by your SSH key. In Settings → Private data repo, click **Test connection** to see which account signs in, and open *Several GitHub accounts on this computer?* for the steps. [More →](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/GUIDE.md#full-setup)
|
|
78
|
+
|
|
79
|
+
**Uploads to my data repo fail?** Click **Test connection** in Settings: it says what's wrong in plain words. See [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md#uploads-fail-or-the-wrong-github-account-is-used).
|
|
80
|
+
|
|
81
|
+
**How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade --pip-args=--no-cache-dir worklog-for-claude-code`.
|
|
78
82
|
|
|
79
83
|
**Something isn't working?** Run `claude-worklog doctor`, or see [Troubleshooting](https://github.com/us-urvin/worklog-for-claude-code/blob/main/docs/TROUBLESHOOTING.md).
|
|
80
84
|
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
# Distribution name on PyPI. The command stays `claude-worklog` and the package stays `worklog`.
|
|
7
7
|
name = "worklog-for-claude-code"
|
|
8
|
-
version = "0.3.
|
|
8
|
+
version = "0.3.2"
|
|
9
9
|
description = "See what you did with Claude Code each day and how many tokens it used: an automatic daily work log across accounts and devices, private by default"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Settings → Private data repo: connected or not, which repo, private or public, and whether syncing works."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from urllib.error import HTTPError
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from test_accounts import make_cfg
|
|
9
|
+
|
|
10
|
+
from worklog import syncstatus, web
|
|
11
|
+
from worklog.store import GitStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def git(repo, *args):
|
|
15
|
+
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def section(cfg):
|
|
19
|
+
return web.data_repo_section(web.App(1), cfg)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def no_network(monkeypatch):
|
|
24
|
+
def fail(*a, **k):
|
|
25
|
+
raise AssertionError("no network expected here")
|
|
26
|
+
monkeypatch.setattr(syncstatus, "urlopen", fail)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_not_a_git_folder_says_not_set_up(home, repo, no_network):
|
|
30
|
+
cfg = make_cfg(home, repo)
|
|
31
|
+
cfg.store_repo = home / "nowhere"
|
|
32
|
+
html = section(cfg)
|
|
33
|
+
assert "Not set up" in html and "git init" in html
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_local_only_explains_how_to_connect(home, repo, no_network):
|
|
37
|
+
html = section(make_cfg(home, repo))
|
|
38
|
+
assert "Local only" in html and "Not connected" in html and "remote add origin" in html and "Copy" in html
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_connected_shows_which_repo_and_never_the_credentials(home, repo, monkeypatch):
|
|
42
|
+
git(repo, "remote", "add", "origin", "https://me:ghp_SECRET123@github.com/me/claude-worklog-data.git")
|
|
43
|
+
monkeypatch.setattr(syncstatus, "urlopen", lambda *a, **k: (_ for _ in ()).throw(
|
|
44
|
+
HTTPError("u", 404, "Not Found", {}, io.BytesIO(b""))))
|
|
45
|
+
syncstatus.visibility("github.com", "me/claude-worklog-data") # what the background check does
|
|
46
|
+
html = section(make_cfg(home, repo))
|
|
47
|
+
assert "Connected" in html and "github.com/me/claude-worklog-data" in html
|
|
48
|
+
assert 'href="https://github.com/me/claude-worklog-data"' in html
|
|
49
|
+
assert "SECRET" not in html and "ghp_" not in html and "me:ghp" not in html
|
|
50
|
+
assert "🔒 Private" in html and "Not uploaded yet" in html
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_public_repo_is_flagged_everywhere(home, repo, monkeypatch):
|
|
54
|
+
git(repo, "remote", "add", "origin", "git@github.com:me/claude-worklog-data.git")
|
|
55
|
+
monkeypatch.setattr(syncstatus, "urlopen", lambda *a, **k: io.BytesIO(json.dumps({"private": False}).encode()))
|
|
56
|
+
assert syncstatus.visibility("github.com", "me/claude-worklog-data") == "public"
|
|
57
|
+
cfg = make_cfg(home, repo)
|
|
58
|
+
assert "PUBLIC" in section(cfg) and "Change visibility" in section(cfg)
|
|
59
|
+
assert "is public" in web._public_repo_warning(cfg) # the warning on every page
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_visibility_is_cached_and_only_asked_for_github(home, monkeypatch):
|
|
63
|
+
calls = []
|
|
64
|
+
|
|
65
|
+
def fake(req, timeout):
|
|
66
|
+
calls.append(req.full_url)
|
|
67
|
+
return io.BytesIO(b'{"private": true}')
|
|
68
|
+
monkeypatch.setattr(syncstatus, "urlopen", fake)
|
|
69
|
+
assert syncstatus.visibility("github.com", "me/r") == "private"
|
|
70
|
+
assert syncstatus.visibility("github.com", "me/r") == "private" and len(calls) == 1 # cached
|
|
71
|
+
assert syncstatus.visibility("gitlab.com", "me/r") == "unknown" and len(calls) == 1 # never asks other hosts
|
|
72
|
+
assert calls == ["https://api.github.com/repos/me/r"]
|
|
73
|
+
monkeypatch.setattr(syncstatus, "urlopen", lambda *a, **k: (_ for _ in ()).throw(OSError("offline")))
|
|
74
|
+
assert syncstatus.visibility("github.com", "me/other") == "unknown"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_store_records_uploads_and_failures(home, repo, tmp_path, no_network):
|
|
78
|
+
bare = tmp_path / "remote.git"
|
|
79
|
+
subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True)
|
|
80
|
+
git(repo, "remote", "add", "origin", str(bare))
|
|
81
|
+
store = GitStore(repo)
|
|
82
|
+
store.write_json("records/2026-11-01/dev/personal/a.json", {"a": 1})
|
|
83
|
+
store.commit_and_push("test")
|
|
84
|
+
assert syncstatus.last("push")["ok"] is True
|
|
85
|
+
git(repo, "remote", "set-url", "origin", str(tmp_path / "missing.git"))
|
|
86
|
+
store.write_json("records/2026-11-01/dev/personal/b.json", {"b": 1})
|
|
87
|
+
store.commit_and_push("test 2")
|
|
88
|
+
failed = syncstatus.last("push")
|
|
89
|
+
assert failed["ok"] is False and failed["error"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_failed_upload_and_waiting_changes_are_shown(home, repo, no_network):
|
|
93
|
+
git(repo, "remote", "add", "origin", "git@gitlab.com:me/data.git")
|
|
94
|
+
GitStore(repo).write_json("records/2026-11-01/dev/personal/a.json", {"a": 1})
|
|
95
|
+
git(repo, "add", "-A")
|
|
96
|
+
subprocess.run(["git", "-C", str(repo), "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"],
|
|
97
|
+
check=True)
|
|
98
|
+
syncstatus.record("push", False, "fatal: Could not read from remote repository.")
|
|
99
|
+
html = section(make_cfg(home, repo))
|
|
100
|
+
assert "Last upload failed" in html and "Could not read from remote repository" in html
|
|
101
|
+
assert "1 change waiting to upload" in html
|
|
102
|
+
assert "Privacy not checked" in html # not GitHub: no request is made
|
|
103
|
+
syncstatus.record("push", True)
|
|
104
|
+
assert "Synced" in section(make_cfg(home, repo))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_test_connection_works_and_explains_failures(home, repo, tmp_path):
|
|
108
|
+
bare = tmp_path / "remote.git"
|
|
109
|
+
subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True)
|
|
110
|
+
git(repo, "remote", "add", "origin", str(bare))
|
|
111
|
+
cfg = make_cfg(home, repo)
|
|
112
|
+
ok, msg = syncstatus.test_connection(cfg)
|
|
113
|
+
assert ok and "Connection works" in msg
|
|
114
|
+
git(repo, "remote", "set-url", "origin", str(tmp_path / "missing.git"))
|
|
115
|
+
ok, msg = syncstatus.test_connection(cfg)
|
|
116
|
+
assert not ok and msg.startswith("Can't reach your data repo")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_sync_works_without_a_git_identity_on_this_computer(home, repo, tmp_path, no_network):
|
|
120
|
+
"""A pull that replays local commits needs a committer name; this computer has none set."""
|
|
121
|
+
bare = tmp_path / "remote.git"
|
|
122
|
+
subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True)
|
|
123
|
+
git(repo, "remote", "add", "origin", str(bare))
|
|
124
|
+
git(repo, "config", "user.useConfigOnly", "true") # like a computer without `git config --global user.email`
|
|
125
|
+
a = GitStore(repo)
|
|
126
|
+
a.write_json("records/2026-11-01/a/personal/1.json", {"n": 1})
|
|
127
|
+
a.commit_and_push("first")
|
|
128
|
+
other = tmp_path / "other"
|
|
129
|
+
subprocess.run(["git", "clone", "-q", str(bare), str(other)], check=True)
|
|
130
|
+
b = GitStore(other)
|
|
131
|
+
b.write_json("records/2026-11-01/b/personal/2.json", {"n": 2})
|
|
132
|
+
b.commit_and_push("from the other computer")
|
|
133
|
+
git(repo, "remote", "set-url", "origin", str(tmp_path / "offline.git")) # local commits pile up while offline
|
|
134
|
+
a.write_json("records/2026-11-01/a/personal/3.json", {"n": 3})
|
|
135
|
+
a.commit_and_push("offline")
|
|
136
|
+
git(repo, "remote", "set-url", "origin", str(bare))
|
|
137
|
+
a.pull()
|
|
138
|
+
assert syncstatus.last("pull")["ok"] is True, syncstatus.last("pull")
|
|
139
|
+
assert (repo / "records/2026-11-01/b/personal/2.json").exists()
|
|
140
|
+
a.commit_and_push("nothing new") # nothing staged, but the waiting commit still goes up
|
|
141
|
+
assert syncstatus.status(make_cfg(home, repo))["waiting"] == 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _fake_ssh(monkeypatch, *, who="us-urvin", ls_remote=(0, "", "")):
|
|
145
|
+
"""ssh -G answers from a ~/.ssh/config alias; ssh -T says who the key is; git ls-remote as given."""
|
|
146
|
+
real_run, real_git = syncstatus.subprocess.run, syncstatus._git
|
|
147
|
+
syncstatus._ssh_cache.clear()
|
|
148
|
+
monkeypatch.setattr(syncstatus.shutil, "which", lambda n: "/usr/bin/" + n)
|
|
149
|
+
|
|
150
|
+
def run(cmd, *a, **k):
|
|
151
|
+
if cmd[0] == "ssh" and "-G" in cmd:
|
|
152
|
+
out = "user git\nhostname github.com\nport 22\nidentityfile ~/.ssh/id_work\n"
|
|
153
|
+
return subprocess.CompletedProcess(cmd, 0, out, "")
|
|
154
|
+
if cmd[0] == "ssh" and "-T" in cmd:
|
|
155
|
+
return subprocess.CompletedProcess(cmd, 1, "", f"Hi {who}! You've successfully authenticated, but GitHub "
|
|
156
|
+
"does not provide shell access.")
|
|
157
|
+
return real_run(cmd, *a, **k)
|
|
158
|
+
monkeypatch.setattr(syncstatus.subprocess, "run", run)
|
|
159
|
+
monkeypatch.setattr(syncstatus, "_git", lambda repo, *args, **k: ls_remote if args[0] == "ls-remote"
|
|
160
|
+
else real_git(repo, *args, **k))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_ssh_nickname_links_to_the_real_host_and_names_the_key(home, repo, monkeypatch):
|
|
164
|
+
git(repo, "remote", "add", "origin", "git@worklog-git:us-urvin/claude-worklog-data.git")
|
|
165
|
+
_fake_ssh(monkeypatch)
|
|
166
|
+
asked = []
|
|
167
|
+
monkeypatch.setattr(syncstatus, "urlopen", lambda req, timeout: asked.append(req.full_url) or (_ for _ in ()).throw(
|
|
168
|
+
HTTPError("u", 404, "Not Found", {}, io.BytesIO(b""))))
|
|
169
|
+
cfg = make_cfg(home, repo)
|
|
170
|
+
r = syncstatus.remote(cfg)
|
|
171
|
+
assert r["host"] == "github.com" and r["alias"] == "worklog-git" and r["key"] == "~/.ssh/id_work"
|
|
172
|
+
assert syncstatus.visibility(r["host"], r["slug"]) == "private" # the privacy check works through the nickname
|
|
173
|
+
html = section(cfg)
|
|
174
|
+
assert 'href="https://github.com/us-urvin/claude-worklog-data"' in html and "https://worklog-git" not in html
|
|
175
|
+
assert "worklog-git" in html and "id_work" in html and "Test connection</b> to see which GitHub account" in html
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_test_connection_says_which_account_signs_in(home, repo, monkeypatch):
|
|
179
|
+
git(repo, "remote", "add", "origin", "git@worklog-git:us-urvin/claude-worklog-data.git")
|
|
180
|
+
_fake_ssh(monkeypatch)
|
|
181
|
+
cfg = make_cfg(home, repo)
|
|
182
|
+
ok, msg = syncstatus.test_connection(cfg)
|
|
183
|
+
assert ok and "signs in to GitHub as us-urvin" in msg
|
|
184
|
+
assert "Signs in as us-urvin" in section(cfg)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_wrong_account_is_explained(home, repo, monkeypatch):
|
|
188
|
+
git(repo, "remote", "add", "origin", "git@gh-office:us-urvin/claude-worklog-data.git")
|
|
189
|
+
err = "ERROR: Repository not found.\nfatal: Could not read from remote repository."
|
|
190
|
+
_fake_ssh(monkeypatch, who="office-u", ls_remote=(128, "", err))
|
|
191
|
+
ok, msg = syncstatus.test_connection(make_cfg(home, repo))
|
|
192
|
+
assert not ok and "knows this computer's key as office-u" in msg
|
|
193
|
+
assert "no access to us-urvin/claude-worklog-data" in msg
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_https_and_local_remotes_are_not_treated_as_ssh(home, repo):
|
|
197
|
+
git(repo, "remote", "add", "origin", "https://us-urvin@github.com/us-urvin/data.git")
|
|
198
|
+
r = syncstatus.remote(make_cfg(home, repo))
|
|
199
|
+
assert r["via"] == "https" and r["user"] == "us-urvin" and r["host"] == "github.com"
|
|
200
|
+
assert syncstatus._ssh_parts("C:/repos/data.git") is None and syncstatus._ssh_parts("/srv/data.git") is None
|
|
201
|
+
assert syncstatus._ssh_parts("ssh://git@work-gh:22/me/r.git") == ("git", "work-gh")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_errors_show_the_line_that_matters():
|
|
205
|
+
err = ("ssh: connect to host github.com port 22: Connection timed out\nfatal: Could not read from remote "
|
|
206
|
+
"repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.\n")
|
|
207
|
+
assert syncstatus._main_line(err).startswith("ssh: connect to host github.com port 22")
|
|
208
|
+
assert syncstatus._main_line("") == "no answer"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Update notice and one-click update. PyPI, pip and the restart are faked here."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from test_accounts import make_cfg
|
|
8
|
+
|
|
9
|
+
from worklog import __version__, updates, web
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def ustate(home, monkeypatch):
|
|
14
|
+
monkeypatch.setattr(updates, "STATE_DIR", home / ".local/state/claude-worklog")
|
|
15
|
+
monkeypatch.delenv("CLAUDE_WORKLOG_DEMO", raising=False)
|
|
16
|
+
return home
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_versions_compare_like_people_expect():
|
|
20
|
+
assert updates.is_newer("0.3.1", "0.3.0") and updates.is_newer("0.10.0", "0.9.9")
|
|
21
|
+
assert not updates.is_newer("0.3.0", "0.3.0") and not updates.is_newer("0.2.9", "0.3.0")
|
|
22
|
+
assert updates.parse("0.4.0rc1") < updates.parse("0.4.0")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_check_asks_pypi_at_most_once_a_day(ustate, monkeypatch):
|
|
26
|
+
calls = []
|
|
27
|
+
|
|
28
|
+
def fake_urlopen(req, timeout):
|
|
29
|
+
calls.append(req.full_url)
|
|
30
|
+
return io.BytesIO(json.dumps({"info": {"version": "9.9.9"}}).encode())
|
|
31
|
+
monkeypatch.setattr(updates, "urlopen", fake_urlopen)
|
|
32
|
+
now = time.time()
|
|
33
|
+
assert updates.check(now=now) == "9.9.9"
|
|
34
|
+
assert updates.check(now=now + 3600) == "9.9.9" and len(calls) == 1 # cached
|
|
35
|
+
updates.check(now=now + 25 * 3600)
|
|
36
|
+
assert len(calls) == 2 and calls[0] == "https://pypi.org/pypi/worklog-for-claude-code/json"
|
|
37
|
+
assert updates.available() == "9.9.9"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_offline_or_odd_answers_are_ignored(ustate, monkeypatch):
|
|
41
|
+
def offline(req, timeout):
|
|
42
|
+
raise OSError("no network")
|
|
43
|
+
monkeypatch.setattr(updates, "urlopen", offline)
|
|
44
|
+
assert updates.check(force=True) is None and updates.available() is None
|
|
45
|
+
monkeypatch.setattr(updates, "urlopen", lambda req, timeout: io.BytesIO(b'{"info": {"version": "<script>"}}'))
|
|
46
|
+
assert updates.check(force=True) is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_upgrade_command_matches_how_it_was_installed(monkeypatch):
|
|
50
|
+
monkeypatch.setattr(updates.shutil, "which", lambda n: "/usr/bin/pipx")
|
|
51
|
+
assert updates.upgrade_command("pipx") == ["/usr/bin/pipx", "upgrade", "--pip-args=--no-cache-dir",
|
|
52
|
+
"worklog-for-claude-code"]
|
|
53
|
+
assert updates.upgrade_command("venv")[-4:] == ["install", "--upgrade", "--no-cache-dir", "worklog-for-claude-code"]
|
|
54
|
+
assert "--no-cache-dir" in updates.manual_command("pipx") # a stale pip cache can't hide the new version
|
|
55
|
+
assert updates.upgrade_command("dev") is None and updates.upgrade_command("system") is None
|
|
56
|
+
assert updates.manual_command("system").startswith("python3 -m pip install --user --upgrade")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _state(ustate, latest):
|
|
60
|
+
p = ustate / ".local/state/claude-worklog/update.json"
|
|
61
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
p.write_text(json.dumps({"checked_at": time.time(), "latest": latest}))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_notice_shows_button_or_command_and_can_be_dismissed(ustate, repo, monkeypatch):
|
|
66
|
+
cfg = make_cfg(ustate, repo)
|
|
67
|
+
app = web.App(1)
|
|
68
|
+
_state(ustate, "9.9.9")
|
|
69
|
+
monkeypatch.setattr(updates, "install_kind", lambda: "pipx")
|
|
70
|
+
html = web._update_html(app, cfg)
|
|
71
|
+
assert "Worklog 9.9.9 is out" in html and f"you have {__version__}" in html and 'action="/update"' in html
|
|
72
|
+
monkeypatch.setattr(updates, "install_kind", lambda: "system")
|
|
73
|
+
html = web._update_html(app, cfg)
|
|
74
|
+
assert 'action="/update"' not in html and "pip install --user --upgrade" in html # copy the command instead
|
|
75
|
+
updates.dismiss("9.9.9")
|
|
76
|
+
assert web._update_html(app, cfg) == ""
|
|
77
|
+
_state(ustate, __version__)
|
|
78
|
+
assert web._update_html(app, cfg) == "" # nothing newer
|
|
79
|
+
cfg.check_updates = False
|
|
80
|
+
_state(ustate, "10.0.0")
|
|
81
|
+
assert web._update_html(app, cfg) == "" # switched off in Settings
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_update_now_upgrades_then_restarts(ustate, repo, monkeypatch):
|
|
85
|
+
make_cfg(ustate, repo)
|
|
86
|
+
monkeypatch.setattr(updates, "install_kind", lambda: "pipx")
|
|
87
|
+
monkeypatch.setattr(updates, "upgrade", lambda: (True, "Updated."))
|
|
88
|
+
monkeypatch.setattr(updates, "installed_version", lambda: "9.9.9")
|
|
89
|
+
restarted = []
|
|
90
|
+
app = web.App(1)
|
|
91
|
+
monkeypatch.setattr(app, "restart", lambda: restarted.append(True))
|
|
92
|
+
monkeypatch.setattr(web.threading, "Timer", lambda delay, fn: type("T", (), {"start": lambda s: fn()})())
|
|
93
|
+
loc, err, _ = app._update({})
|
|
94
|
+
assert err is None and loc == "/?ok=job"
|
|
95
|
+
for _ in range(100):
|
|
96
|
+
if not app.jobs.snapshot()["running"]:
|
|
97
|
+
break
|
|
98
|
+
time.sleep(0.02)
|
|
99
|
+
assert app.jobs.snapshot()["message"] == "Updated to 9.9.9. Restarting…" and restarted == [True]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pypi(version, uploaded):
|
|
103
|
+
files = [{"upload_time_iso_8601": uploaded}] if uploaded else []
|
|
104
|
+
body = json.dumps({"info": {"version": version}, "releases": {version: files}, "urls": files}).encode()
|
|
105
|
+
return lambda req, timeout: io.BytesIO(body)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_a_release_is_announced_only_once_pypi_has_settled(ustate, monkeypatch):
|
|
109
|
+
from datetime import datetime, timezone
|
|
110
|
+
t0 = datetime(2026, 11, 2, 12, 0, tzinfo=timezone.utc).timestamp()
|
|
111
|
+
monkeypatch.setattr(updates, "urlopen", _pypi("9.9.9", "2026-11-02T12:00:00.000000Z"))
|
|
112
|
+
assert updates.check(force=True, now=t0 + 60) is None # just published: pip may not see it yet
|
|
113
|
+
assert updates.check(now=t0 + 600) is None # not re-checked every request...
|
|
114
|
+
assert updates.check(now=t0 + 60 + updates.RETRY) == "9.9.9" # ...but an hour later, not a day
|
|
115
|
+
monkeypatch.setattr(updates, "urlopen", _pypi("10.0.0", None))
|
|
116
|
+
assert updates.fetch_latest() is None # listed but no files uploaded yet
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_update_now_explains_when_pypi_is_still_catching_up(ustate, repo, monkeypatch):
|
|
120
|
+
make_cfg(ustate, repo)
|
|
121
|
+
_state(ustate, "9.9.9")
|
|
122
|
+
monkeypatch.setattr(updates, "install_kind", lambda: "pipx")
|
|
123
|
+
monkeypatch.setattr(updates, "upgrade", lambda: (True, "Updated."))
|
|
124
|
+
monkeypatch.setattr(updates, "installed_version", lambda: __version__) # pip found nothing newer
|
|
125
|
+
app = web.App(1)
|
|
126
|
+
app._update({})
|
|
127
|
+
for _ in range(100):
|
|
128
|
+
if not app.jobs.snapshot()["running"]:
|
|
129
|
+
break
|
|
130
|
+
time.sleep(0.02)
|
|
131
|
+
snap = app.jobs.snapshot()
|
|
132
|
+
assert "still publishing Worklog 9.9.9" in snap["message"] and "Try again in a few minutes" in snap["message"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_failed_update_says_what_to_do(ustate, repo, monkeypatch):
|
|
136
|
+
monkeypatch.setattr(updates, "install_kind", lambda: "venv")
|
|
137
|
+
monkeypatch.setattr(updates.subprocess, "run",
|
|
138
|
+
lambda *a, **k: updates.subprocess.CompletedProcess(a, 1, "", "error: externally-managed"))
|
|
139
|
+
ok, msg = updates.upgrade()
|
|
140
|
+
assert not ok and "externally-managed" in msg and "Try by hand" in msg
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_updated_message_is_shown_once_and_only_by_the_new_version(ustate):
|
|
144
|
+
updates.note_updated("9.9.9")
|
|
145
|
+
assert updates.pop_updated("0.3.0") is None # the old process, just before it restarts
|
|
146
|
+
assert updates.pop_updated("9.9.9") == "9.9.9" # the new one
|
|
147
|
+
assert updates.pop_updated("9.9.9") is None # only once
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _job(message, ok=True, age=0.0):
|
|
151
|
+
return {"running": None, "finished": "11:21", "finished_at": time.time() - age, "message": message, "ok": ok}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_job_results_go_away_by_themselves_and_can_be_closed():
|
|
155
|
+
page = web.layout("t", "", "day", "n", _job("Updated 178 session record(s)"), csrf="tok")
|
|
156
|
+
assert "Updated 178 session record(s)" in page and 'action="/jobs/dismiss"' in page
|
|
157
|
+
assert "Updated 178" not in web.layout("t", "", "day", "n", _job("Updated 178 session record(s)", age=121))
|
|
158
|
+
failed = _job("Syncing failed: offline", ok=False, age=300)
|
|
159
|
+
assert "Syncing failed" in web.layout("t", "", "day", "n", failed) # errors stay longer
|
|
160
|
+
assert "Syncing failed" not in web.layout("t", "", "day", "n", _job("Syncing failed", ok=False, age=901))
|
|
161
|
+
restarting = _job("Updated to 9.9.9. Restarting…", age=600)
|
|
162
|
+
assert 'id="restarting"' in web.layout("t", "", "day", "n", restarting) # never hidden while restarting
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_dismiss_clears_the_last_result(ustate, repo, monkeypatch):
|
|
166
|
+
cfg = make_cfg(ustate, repo)
|
|
167
|
+
monkeypatch.setattr(web, "_try_config", lambda: cfg)
|
|
168
|
+
app = web.App(1)
|
|
169
|
+
app.jobs.start("Syncing", lambda: "Synced: 3 records")
|
|
170
|
+
for _ in range(100):
|
|
171
|
+
if not app.jobs.snapshot()["running"]:
|
|
172
|
+
break
|
|
173
|
+
time.sleep(0.02)
|
|
174
|
+
assert app.jobs.snapshot()["message"] == "Synced: 3 records"
|
|
175
|
+
app.post("/jobs/dismiss", {})
|
|
176
|
+
assert app.jobs.snapshot()["message"] == ""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_version_on_disk_reads_the_installed_files():
|
|
180
|
+
assert updates.version_on_disk() == __version__
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_restart_banner_after_a_terminal_upgrade(ustate, repo, monkeypatch):
|
|
184
|
+
cfg = make_cfg(ustate, repo)
|
|
185
|
+
monkeypatch.setattr(web, "_try_config", lambda: cfg)
|
|
186
|
+
app = web.App(1)
|
|
187
|
+
assert web._restart_html(app) == "" # same version on disk: nothing to do
|
|
188
|
+
monkeypatch.setattr(updates, "version_on_disk", lambda: "9.9.9")
|
|
189
|
+
html = web._restart_html(app)
|
|
190
|
+
assert "Worklog 9.9.9 is installed" in html and f"still runs {__version__}" in html and 'action="/restart"' in html
|
|
191
|
+
restarted = []
|
|
192
|
+
monkeypatch.setattr(app, "restart", lambda: restarted.append(True))
|
|
193
|
+
monkeypatch.setattr(web.threading, "Timer", lambda delay, fn: type("T", (), {"start": lambda s: fn()})())
|
|
194
|
+
loc, err, _ = app.post("/restart", {})
|
|
195
|
+
assert err is None and loc == "/?ok=job"
|
|
196
|
+
for _ in range(100):
|
|
197
|
+
if not app.jobs.snapshot()["running"]:
|
|
198
|
+
break
|
|
199
|
+
time.sleep(0.02)
|
|
200
|
+
assert app.jobs.snapshot()["message"] == "Updated to 9.9.9. Restarting…" and restarted == [True]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_script_sent_forms_get_answers_in_place(ustate, repo):
|
|
204
|
+
app = web.App(1)
|
|
205
|
+
assert web._fetch_answer(app, "/settings/watch", None, "Bad value") == {"ok": False, "error": "Bad value"}
|
|
206
|
+
assert web._fetch_answer(app, "/settings/watch", "/settings?ok=saved", None) == {
|
|
207
|
+
"ok": True, "message": web.OK_MESSAGES["saved"]}
|
|
208
|
+
assert web._fetch_answer(app, "/sync/test", "/settings?ok=job#data-repo", None)["job"] # follow the job
|
|
209
|
+
app.jobs.start("Syncing", lambda: time.sleep(0.3) or "done")
|
|
210
|
+
busy = web._fetch_answer(app, "/sync/test", "/settings#data-repo", None) # couldn't start: something else runs
|
|
211
|
+
assert busy["ok"] is False and "Syncing" in busy["error"]
|
|
@@ -57,11 +57,22 @@ class GitStore:
|
|
|
57
57
|
def _has_remote(self) -> bool:
|
|
58
58
|
return bool(self._git("remote", check=False).stdout.strip())
|
|
59
59
|
|
|
60
|
+
def _unpushed(self) -> bool:
|
|
61
|
+
"""Commits that an earlier run couldn't upload (offline, or a failed pull) are still waiting."""
|
|
62
|
+
if not self._has_remote():
|
|
63
|
+
return False
|
|
64
|
+
r = self._git("rev-list", "--count", "@{u}..HEAD", check=False)
|
|
65
|
+
if r.returncode != 0: # never pushed: anything committed is waiting
|
|
66
|
+
r = self._git("rev-list", "--count", "HEAD", check=False)
|
|
67
|
+
return r.returncode == 0 and r.stdout.strip() not in ("", "0")
|
|
68
|
+
|
|
60
69
|
def pull(self) -> None:
|
|
61
70
|
if not self._has_remote():
|
|
62
71
|
return
|
|
63
72
|
try:
|
|
64
|
-
|
|
73
|
+
# Replaying local commits on top of the remote ones writes new commits, so git needs a name
|
|
74
|
+
# here too: without it a computer with no `git config --global user.email` can never sync.
|
|
75
|
+
self._git(*_IDENTITY, "pull", "--rebase", "--autostash", timeout=90)
|
|
65
76
|
syncstatus.record("pull", True)
|
|
66
77
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
67
78
|
# Offline, empty repo, or first run: work locally, sync next time.
|
|
@@ -80,9 +91,10 @@ class GitStore:
|
|
|
80
91
|
attrs.write_text("records/** text eol=lf\nreports/** text eol=lf\n", encoding="utf-8", newline="\n")
|
|
81
92
|
dirs.append(".gitattributes")
|
|
82
93
|
self._git("add", "-A", "--", *dirs)
|
|
83
|
-
if self._git("diff", "--cached", "--quiet", check=False).returncode
|
|
84
|
-
|
|
85
|
-
self.
|
|
94
|
+
if self._git("diff", "--cached", "--quiet", check=False).returncode != 0:
|
|
95
|
+
self._git(*_IDENTITY, "commit", "-q", "-m", message)
|
|
96
|
+
elif not self._unpushed():
|
|
97
|
+
return # nothing new, and nothing left over from an earlier failed upload
|
|
86
98
|
if not self._has_remote():
|
|
87
99
|
log.info("committed locally (no remote configured)")
|
|
88
100
|
return
|