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.
- {worklog_for_claude_code-0.3.0/worklog_for_claude_code.egg-info → worklog_for_claude_code-0.3.2}/PKG-INFO +7 -3
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/README.md +5 -1
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/pyproject.toml +2 -2
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_chats.py +2 -2
- worklog_for_claude_code-0.3.2/tests/test_forget.py +170 -0
- 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.0 → worklog_for_claude_code-0.3.2}/worklog/__init__.py +1 -1
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/collector.py +4 -1
- worklog_for_claude_code-0.3.2/worklog/forget.py +211 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/identity.py +10 -1
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/store.py +21 -4
- worklog_for_claude_code-0.3.2/worklog/syncstatus.py +298 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/updates.py +57 -8
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/web.py +383 -32
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2/worklog_for_claude_code.egg-info}/PKG-INFO +7 -3
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/SOURCES.txt +4 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog_for_claude_code.egg-info/requires.txt +1 -1
- worklog_for_claude_code-0.3.0/tests/test_updates.py +0 -112
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/LICENSE +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/setup.cfg +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_accounts.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_checks.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_demo.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_platforms.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_viz.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_watcher.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/tests/test_worklog.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/chats.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/checks.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/cli.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/config.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/demo.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/hooks.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/locks.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/parser.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/redact.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/report.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/services.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/viz.py +0 -0
- {worklog_for_claude_code-0.3.0 → worklog_for_claude_code-0.3.2}/worklog/watcher.py +0 -0
- {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
- {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
- {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.
|
|
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.
|
|
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
|
-
**
|
|
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"
|
|
@@ -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.
|
|
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't be undone" in card and "Claude Code'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"
|