worklog-for-claude-code 0.3.0__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 (44) hide show
  1. {worklog_for_claude_code-0.3.0/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.3.2}/PKG-INFO +7 -3
  2. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/README.md +5 -1
  3. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/pyproject.toml +2 -2
  4. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_chats.py +2 -2
  5. worklog_for_claude_code-0.3.2/tests/test_forget.py +170 -0
  6. worklog_for_claude_code-0.3.2/tests/test_syncstatus.py +208 -0
  7. worklog_for_claude_code-0.3.2/tests/test_updates.py +211 -0
  8. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/__init__.py +1 -1
  9. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/collector.py +4 -1
  10. worklog_for_claude_code-0.3.2/worklog/forget.py +211 -0
  11. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/identity.py +10 -1
  12. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/store.py +21 -4
  13. worklog_for_claude_code-0.3.2/worklog/syncstatus.py +298 -0
  14. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/updates.py +57 -8
  15. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/web.py +383 -32
  16. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2/worklog_for_claude_code.egg-info}/PKG-INFO +7 -3
  17. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/SOURCES.txt +4 -0
  18. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/requires.txt +1 -1
  19. worklog_for_claude_code-0.3.0/tests/test_updates.py +0 -112
  20. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/LICENSE +0 -0
  21. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/setup.cfg +0 -0
  22. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_accounts.py +0 -0
  23. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_checks.py +0 -0
  24. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_demo.py +0 -0
  25. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_platforms.py +0 -0
  26. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_viz.py +0 -0
  27. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_watcher.py +0 -0
  28. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_worklog.py +0 -0
  29. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/chats.py +0 -0
  30. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/checks.py +0 -0
  31. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/cli.py +0 -0
  32. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/config.py +0 -0
  33. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/demo.py +0 -0
  34. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/hooks.py +0 -0
  35. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/locks.py +0 -0
  36. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/parser.py +0 -0
  37. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/redact.py +0 -0
  38. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/report.py +0 -0
  39. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/services.py +0 -0
  40. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/viz.py +0 -0
  41. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/watcher.py +0 -0
  42. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/dependency_links.txt +0 -0
  43. {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/entry_points.txt +0 -0
  44. {worklog_for_claude_code-0.3.0 → 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.0
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
@@ -33,7 +33,7 @@ Provides-Extra: test
33
33
  Requires-Dist: pytest>=7; extra == "test"
34
34
  Provides-Extra: dev
35
35
  Requires-Dist: pytest>=7; extra == "dev"
36
- Requires-Dist: ruff==0.17.0; extra == "dev"
36
+ Requires-Dist: ruff==0.17.3; extra == "dev"
37
37
  Requires-Dist: build; extra == "dev"
38
38
  Dynamic: license-file
39
39
 
@@ -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.0"
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"
@@ -35,7 +35,7 @@ dependencies = ['tomli>=2.0; python_version < "3.11"']
35
35
 
36
36
  [project.optional-dependencies]
37
37
  test = ["pytest>=7"]
38
- dev = ["pytest>=7", "ruff==0.17.0", "build"]
38
+ dev = ["pytest>=7", "ruff==0.17.3", "build"]
39
39
 
40
40
  [project.urls]
41
41
  Homepage = "https://github.com/us-urvin/worklog-for-claude-code"
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
 
14
14
  import pytest
15
15
 
16
- from worklog import chats, cli, identity, store, web
16
+ from worklog import chats, cli, identity, store, syncstatus, web
17
17
  from worklog.config import ConfigError, dump_config, load_config
18
18
  from worklog.report import aggregate, render
19
19
 
@@ -73,7 +73,7 @@ def home(tmp_path, monkeypatch):
73
73
  ("APPDATA", "AppData/Roaming"), ("LOCALAPPDATA", "AppData/Local")):
74
74
  monkeypatch.setenv(var, str(h / sub) if sub else str(h))
75
75
  state = h / ".local/state/claude-worklog"
76
- for mod in (identity, store, cli, chats):
76
+ for mod in (identity, store, cli, chats, syncstatus):
77
77
  monkeypatch.setattr(mod, "STATE_DIR", state)
78
78
  assert Path.home() == h
79
79
  return h
@@ -0,0 +1,170 @@
1
+ """Forget a login: deletes exactly its Worklog history on this device, after a confirmation."""
2
+ import json
3
+ from datetime import datetime, timedelta
4
+
5
+ import pytest
6
+ from test_accounts import EMAIL_A, EMAIL_B, UID_A, UID_B, login, make_cfg, records, start_session, transcript
7
+
8
+ from worklog import collector, forget, identity, watcher, web
9
+ from worklog.config import load_config, save_config
10
+
11
+ UID_C, EMAIL_C = "33333333-cccc-4ccc-8ccc-333333333333", "stranger@viitor.example"
12
+
13
+
14
+ @pytest.fixture
15
+ def isolated(home, monkeypatch):
16
+ wpath = home / ".local/state/claude-worklog/watch.json"
17
+ monkeypatch.setattr(watcher, "state_path", lambda: wpath)
18
+ monkeypatch.setattr(collector, "watch_state", lambda: watcher.load_state(wpath))
19
+ return home
20
+
21
+
22
+ def setup_user_case(home, repo):
23
+ """Like the reported setup: three logins in ~/.claude, two named, one ("stranger") without a name."""
24
+ cfg = make_cfg(home, repo, f'[[accounts]]\nname = "laracopilot"\nconfig_dir = "~/.claude"\n'
25
+ f'match_account_id = "{UID_B}"\n')
26
+ cfg.accounts[0] = cfg.accounts[0].__class__("personal", cfg.accounts[0].config_dir, match_account_id=UID_A)
27
+ cfg.accounts.reverse() # laracopilot first: it is the folder default for logins without a name
28
+ save_config(cfg, cfg.path)
29
+ cfg = load_config(cfg.path)
30
+ now = datetime.now().astimezone().replace(microsecond=0)
31
+ for uid, email, sid, days_ago in ((UID_A, EMAIL_A, "s-personal", 0), (UID_B, EMAIL_B, "s-lara", 0),
32
+ (UID_C, EMAIL_C, "s-stranger-old", 6), (UID_C, EMAIL_C, "s-stranger", 0)):
33
+ login(home, uid, email)
34
+ start_session(home, sid)
35
+ transcript(home, sid, now - timedelta(days=days_ago), home)
36
+ collector.collect(cfg, [now.date() - timedelta(days=6), now.date()])
37
+ return cfg, now
38
+
39
+
40
+ def paths(repo):
41
+ return sorted(records(repo))
42
+
43
+
44
+ def test_forget_an_unnamed_login_deletes_only_its_sessions(isolated, repo):
45
+ cfg, now = setup_user_case(isolated, repo)
46
+ before = paths(repo)
47
+ assert len(before) == 4 and sum("s-stranger" in p for p in before) == 2
48
+ p = forget.plan(cfg, identity.ref(UID_C))
49
+ assert p.sessions == {"s-stranger", "s-stranger-old"} and len(p.files) == 2 and len(p.days) == 2
50
+ assert p.account is None # it was never named: no account goes away
51
+
52
+ msg = forget.apply(load_config(cfg.path), p)
53
+ assert "deleted 2 session record(s) from 2 day(s)" in msg
54
+ after = paths(repo)
55
+ assert not any("s-stranger" in x for x in after) and len(after) == 2
56
+ assert {x.split("/")[3] for x in after} == {"personal", "laracopilot"}
57
+ assert UID_C not in {x["account_id"] for x in identity.detected_logins()}
58
+
59
+ # a later sync never writes the forgotten sessions again
60
+ collector.collect(load_config(cfg.path), [now.date() - timedelta(days=6), now.date()])
61
+ assert not any("s-stranger" in x for x in paths(repo))
62
+
63
+
64
+ def test_forget_a_named_login_removes_its_account_and_rehomes_the_rest(isolated, repo):
65
+ cfg, now = setup_user_case(isolated, repo)
66
+ cfg.summarizer_account = "laracopilot"
67
+ save_config(cfg, cfg.path)
68
+ p = forget.plan(load_config(cfg.path), identity.ref(UID_B))
69
+ assert p.account == "laracopilot" and p.sessions == {"s-lara"}
70
+ msg = forget.apply(load_config(cfg.path), p)
71
+ new = load_config(cfg.path)
72
+ assert new.account("laracopilot") is None and new.summarizer_account == "personal"
73
+ assert "removed account laracopilot" in msg and "moved 2 other record(s)" in msg
74
+ after = paths(repo)
75
+ assert not any("s-lara" in x for x in after)
76
+ # the stranger's sessions had fallen back to laracopilot: now with the folder's new default, not orphaned
77
+ assert all("/laracopilot/" not in x for x in after)
78
+ assert sum("/personal/s-stranger" in x for x in after) == 2
79
+ assert records(repo)[next(x for x in after if "s-stranger" in x)]["account"] == "personal"
80
+
81
+
82
+ def test_forget_the_only_account_keeps_it_but_unlinks(isolated, repo):
83
+ cfg = make_cfg(isolated, repo, "")
84
+ cfg.accounts[0] = cfg.accounts[0].__class__("solo", cfg.accounts[0].config_dir, match_account_id=UID_A)
85
+ cfg.summarizer_account = "solo"
86
+ save_config(cfg, cfg.path)
87
+ cfg = load_config(cfg.path)
88
+ login(isolated, UID_A, EMAIL_A)
89
+ start_session(isolated, "s1")
90
+ transcript(isolated, "s1", datetime.now().astimezone(), isolated)
91
+ collector.collect(cfg, [datetime.now().date()])
92
+ p = forget.plan(cfg, identity.ref(UID_A))
93
+ assert p.keep_account
94
+ forget.apply(cfg, p)
95
+ new = load_config(cfg.path)
96
+ assert [a.name for a in new.accounts] == ["solo"] and new.accounts[0].match_account_id is None
97
+ assert paths(repo) == []
98
+
99
+
100
+ def test_forget_finds_sessions_the_watcher_matched(isolated, repo):
101
+ cfg = make_cfg(isolated, repo,
102
+ f'[[accounts]]\nname = "work"\nconfig_dir = "~/.claude"\nmatch_account_id = "{UID_B}"\n')
103
+ now = datetime.now().astimezone().replace(microsecond=0)
104
+ w = watcher.Watcher(watcher.state_path())
105
+ t = now - timedelta(minutes=20)
106
+ login(isolated, UID_B, EMAIL_B) # switched by some tool; no hook anywhere
107
+ while t <= now:
108
+ w._last_discover = float("inf")
109
+ w.step(now=t)
110
+ t += timedelta(seconds=30)
111
+ transcript(isolated, "s-w", now - timedelta(minutes=10), isolated)
112
+ collector.collect(cfg, [now.date()])
113
+ rec = records(repo)[f"records/{now.date().isoformat()}/dev1/work/s-w.json"]
114
+ assert rec["account_source"] == "watcher"
115
+ p = forget.plan(cfg, identity.ref(UID_B))
116
+ assert p.sessions == {"s-w"} and p.account == "work"
117
+
118
+
119
+ def test_forget_drops_the_saved_summaries_of_those_projects(isolated, repo):
120
+ cfg, now = setup_user_case(isolated, repo)
121
+ d = now.date().isoformat()
122
+ rep = repo / "reports" / d
123
+ rep.mkdir(parents=True)
124
+ (rep / "dev1.json").write_text(json.dumps({"summaries": {"misc:R&D / misc": {"bullets": ["- secret work"]},
125
+ "other": {"bullets": ["- keep me"]}}}))
126
+ (rep / "dev1.md").write_text("- secret work\n")
127
+ forget.apply(load_config(cfg.path), forget.plan(cfg, identity.ref(UID_C)))
128
+ meta = json.loads((rep / "dev1.json").read_text())
129
+ assert "misc:R&D / misc" not in meta["summaries"] and "other" in meta["summaries"]
130
+ assert not (rep / "dev1.md").exists()
131
+
132
+
133
+ def test_the_same_account_logging_in_again_is_tracked_again(isolated, repo):
134
+ cfg, now = setup_user_case(isolated, repo)
135
+ forget.apply(load_config(cfg.path), forget.plan(cfg, identity.ref(UID_C)))
136
+ login(isolated, UID_C, EMAIL_C)
137
+ start_session(isolated, "s-stranger-new")
138
+ transcript(isolated, "s-stranger-new", now, isolated)
139
+ collector.collect(load_config(cfg.path), [now.date()])
140
+ assert any("s-stranger-new" in x for x in paths(repo))
141
+ assert UID_C in {x["account_id"] for x in identity.detected_logins()}
142
+
143
+
144
+ def test_ui_asks_first_and_explains_what_is_lost(isolated, repo, monkeypatch):
145
+ cfg, _ = setup_user_case(isolated, repo)
146
+ monkeypatch.setattr(web, "_try_config", lambda: load_config(cfg.path))
147
+ app = web.App(8765)
148
+ ref = identity.ref(UID_C)
149
+ settings = web.page_settings(app, cfg)
150
+ assert f'href="/settings?forget={ref}#forget"' in settings and "Forget…" in settings
151
+
152
+ card = web.page_settings(app, cfg, ref)
153
+ assert "Forget s***@viitor.example?" in card and "2 Claude Code session records" in card
154
+ assert "can&#39;t be undone" in card and "Claude Code&#39;s own files" in card and 'required' in card
155
+ assert EMAIL_C not in card and UID_C not in card # masked, as everywhere
156
+
157
+ n = len(paths(repo))
158
+ loc, err, _ = app.post("/accounts/forget", {"ref": ref}) # the box isn't ticked: nothing happens
159
+ assert err and len(paths(repo)) == n
160
+ loc, err, _ = app.post("/accounts/forget", {"ref": "000000000000", "confirm": "1"})
161
+ assert err and len(paths(repo)) == n
162
+ loc, err, _ = app.post("/accounts/forget", {"ref": ref, "confirm": "1"})
163
+ assert err is None and loc.startswith("/settings?ok=job")
164
+ import time
165
+ for _ in range(200):
166
+ if not app.jobs.snapshot()["running"]:
167
+ break
168
+ time.sleep(0.02)
169
+ assert app.jobs.snapshot()["ok"], app.jobs.snapshot()["message"]
170
+ assert not any("s-stranger" in x for x in paths(repo))
@@ -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"