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.
Files changed (46) hide show
  1. {worklog_for_claude_code-0.3.1/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.3.2}/PKG-INFO +6 -2
  2. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/README.md +5 -1
  3. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/pyproject.toml +1 -1
  4. worklog_for_claude_code-0.3.2/tests/test_syncstatus.py +208 -0
  5. worklog_for_claude_code-0.3.2/tests/test_updates.py +211 -0
  6. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/__init__.py +1 -1
  7. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/store.py +16 -4
  8. worklog_for_claude_code-0.3.2/worklog/syncstatus.py +298 -0
  9. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/updates.py +57 -8
  10. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/web.py +227 -22
  11. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2/worklog_for_claude_code.egg-info}/PKG-INFO +6 -2
  12. worklog_for_claude_code-0.3.1/tests/test_syncstatus.py +0 -116
  13. worklog_for_claude_code-0.3.1/tests/test_updates.py +0 -112
  14. worklog_for_claude_code-0.3.1/worklog/syncstatus.py +0 -148
  15. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/LICENSE +0 -0
  16. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/setup.cfg +0 -0
  17. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_accounts.py +0 -0
  18. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_chats.py +0 -0
  19. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_checks.py +0 -0
  20. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_demo.py +0 -0
  21. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_forget.py +0 -0
  22. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_platforms.py +0 -0
  23. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_viz.py +0 -0
  24. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_watcher.py +0 -0
  25. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/tests/test_worklog.py +0 -0
  26. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/chats.py +0 -0
  27. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/checks.py +0 -0
  28. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/cli.py +0 -0
  29. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/collector.py +0 -0
  30. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/config.py +0 -0
  31. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/demo.py +0 -0
  32. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/forget.py +0 -0
  33. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/hooks.py +0 -0
  34. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/identity.py +0 -0
  35. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/locks.py +0 -0
  36. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/parser.py +0 -0
  37. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/redact.py +0 -0
  38. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/report.py +0 -0
  39. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/services.py +0 -0
  40. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/viz.py +0 -0
  41. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog/watcher.py +0 -0
  42. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/SOURCES.txt +0 -0
  43. {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
  44. {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
  45. {worklog_for_claude_code-0.3.1 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/requires.txt +0 -0
  46. {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.1
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
- **How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade worklog-for-claude-code`.
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
- **How do I update?** Click **Update now** when the dashboard says a new version is out, or run `pipx upgrade worklog-for-claude-code`.
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.1"
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"]
@@ -1,3 +1,3 @@
1
1
  """claude-worklog: daily work log + token report for Claude Code."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
@@ -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
- self._git("pull", "--rebase", "--autostash", timeout=90)
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 == 0:
84
- return # nothing staged
85
- self._git(*_IDENTITY, "commit", "-q", "-m", message)
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