workstream-cli 0.0.1__py3-none-any.whl
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.
- workstream/ARCHITECTURE.md +89 -0
- workstream/__init__.py +8 -0
- workstream/cli.py +136 -0
- workstream/commands/__init__.py +0 -0
- workstream/commands/backfill.py +139 -0
- workstream/commands/block.py +93 -0
- workstream/commands/checkin.py +51 -0
- workstream/commands/cron.py +119 -0
- workstream/commands/focus_cmd.py +273 -0
- workstream/commands/idea.py +172 -0
- workstream/commands/index.py +89 -0
- workstream/commands/init.py +567 -0
- workstream/commands/inspect_cmd.py +354 -0
- workstream/commands/list_cmd.py +99 -0
- workstream/commands/nest.py +108 -0
- workstream/commands/new.py +95 -0
- workstream/commands/next_cmd.py +333 -0
- workstream/commands/report.py +190 -0
- workstream/commands/resume.py +145 -0
- workstream/commands/review.py +227 -0
- workstream/commands/serve.py +23 -0
- workstream/commands/setup.py +178 -0
- workstream/commands/show.py +123 -0
- workstream/commands/snooze.py +117 -0
- workstream/commands/stale.py +116 -0
- workstream/commands/sweep.py +1753 -0
- workstream/commands/tree.py +105 -0
- workstream/commands/update_status.py +117 -0
- workstream/config.py +322 -0
- workstream/extensions/__init__.py +0 -0
- workstream/extensions/workstream.ts +633 -0
- workstream/focus_artifact.py +157 -0
- workstream/git.py +194 -0
- workstream/harness.py +49 -0
- workstream/llm.py +78 -0
- workstream/markdown.py +501 -0
- workstream/models.py +274 -0
- workstream/plan_index.py +88 -0
- workstream/provisioning.py +196 -0
- workstream/repo_discovery.py +158 -0
- workstream/review_artifact.py +96 -0
- workstream/scripts/migrate_statuses.py +120 -0
- workstream/skills/__init__.py +0 -0
- workstream/skills/workstream_context/SKILL.md +75 -0
- workstream/skills/workstream_context/__init__.py +0 -0
- workstream/skills/workstream_focus/SKILL.md +141 -0
- workstream/skills/workstream_init/SKILL.md +86 -0
- workstream/skills/workstream_review/SKILL.md +224 -0
- workstream/skills/workstream_sweep/SKILL.md +178 -0
- workstream/sweep_state.py +93 -0
- workstream/templates/dashboard.html +382 -0
- workstream/templates/detail.html +360 -0
- workstream/templates/plan.html +210 -0
- workstream/test/__init__.py +0 -0
- workstream/test/conftest.py +221 -0
- workstream/test/fixtures/sample_sprint_note.md +10 -0
- workstream/test/fixtures/sample_workstream.md +41 -0
- workstream/test/test_backfill.py +180 -0
- workstream/test/test_batch_writeback.py +81 -0
- workstream/test/test_commands.py +938 -0
- workstream/test/test_config.py +54 -0
- workstream/test/test_focus_artifact.py +211 -0
- workstream/test/test_git.py +88 -0
- workstream/test/test_heuristics.py +136 -0
- workstream/test/test_hierarchy.py +231 -0
- workstream/test/test_init.py +452 -0
- workstream/test/test_inspect.py +143 -0
- workstream/test/test_llm.py +78 -0
- workstream/test/test_markdown.py +626 -0
- workstream/test/test_models.py +506 -0
- workstream/test/test_next.py +206 -0
- workstream/test/test_plan_index.py +83 -0
- workstream/test/test_provisioning.py +270 -0
- workstream/test/test_repo_discovery.py +181 -0
- workstream/test/test_resume.py +71 -0
- workstream/test/test_sweep.py +1196 -0
- workstream/test/test_sweep_state.py +86 -0
- workstream/test/test_thoughts.py +516 -0
- workstream/test/test_web.py +606 -0
- workstream/thoughts.py +505 -0
- workstream/web.py +444 -0
- workstream_cli-0.0.1.dist-info/LICENSE +21 -0
- workstream_cli-0.0.1.dist-info/METADATA +93 -0
- workstream_cli-0.0.1.dist-info/RECORD +86 -0
- workstream_cli-0.0.1.dist-info/WHEEL +4 -0
- workstream_cli-0.0.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tests for workstream.config — auto_commit_notes field."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from workstream.config import Config, _parse_config_data, save_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_auto_commit_notes_default_true():
|
|
11
|
+
"""When absent from YAML, auto_commit_notes defaults to True."""
|
|
12
|
+
data = {'repos': [], 'notes_dirs': [], 'repo_dirs': [], 'llm': 'auto'}
|
|
13
|
+
config = _parse_config_data(data, '/tmp/ws')
|
|
14
|
+
assert config.auto_commit_notes is True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_auto_commit_notes_explicit_false():
|
|
18
|
+
"""YAML `auto_commit_notes: false` parses to False."""
|
|
19
|
+
data = {
|
|
20
|
+
'repos': [], 'notes_dirs': [], 'repo_dirs': [],
|
|
21
|
+
'llm': 'auto', 'auto_commit_notes': False,
|
|
22
|
+
}
|
|
23
|
+
config = _parse_config_data(data, '/tmp/ws')
|
|
24
|
+
assert config.auto_commit_notes is False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_auto_commit_notes_explicit_true():
|
|
28
|
+
"""YAML `auto_commit_notes: true` parses to True."""
|
|
29
|
+
data = {
|
|
30
|
+
'repos': [], 'notes_dirs': [], 'repo_dirs': [],
|
|
31
|
+
'llm': 'auto', 'auto_commit_notes': True,
|
|
32
|
+
}
|
|
33
|
+
config = _parse_config_data(data, '/tmp/ws')
|
|
34
|
+
assert config.auto_commit_notes is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_save_config_includes_auto_commit_notes(tmp_path: Path):
|
|
38
|
+
"""save_config persists auto_commit_notes to the project config file."""
|
|
39
|
+
ws_dir = tmp_path / '_Workstreams'
|
|
40
|
+
ws_dir.mkdir()
|
|
41
|
+
config = Config(workstreams_dir=str(ws_dir), auto_commit_notes=False)
|
|
42
|
+
save_config(config)
|
|
43
|
+
written = yaml.safe_load((ws_dir / 'config.yaml').read_text())
|
|
44
|
+
assert written['auto_commit_notes'] is False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_save_config_auto_commit_notes_true(tmp_path: Path):
|
|
48
|
+
"""save_config persists auto_commit_notes=True."""
|
|
49
|
+
ws_dir = tmp_path / '_Workstreams'
|
|
50
|
+
ws_dir.mkdir()
|
|
51
|
+
config = Config(workstreams_dir=str(ws_dir), auto_commit_notes=True)
|
|
52
|
+
save_config(config)
|
|
53
|
+
written = yaml.safe_load((ws_dir / 'config.yaml').read_text())
|
|
54
|
+
assert written['auto_commit_notes'] is True
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Tests for workstream.focus_artifact — focus doc I/O and lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from workstream.focus_artifact import (
|
|
11
|
+
FocusArtifact,
|
|
12
|
+
focus_dir,
|
|
13
|
+
load_current_week_focus,
|
|
14
|
+
load_focus,
|
|
15
|
+
load_latest_focus,
|
|
16
|
+
save_focus,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── FocusArtifact construction ────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def test_focus_artifact_defaults():
|
|
23
|
+
a = FocusArtifact()
|
|
24
|
+
assert a.date == date.today().isoformat()
|
|
25
|
+
assert a.type == 'focus'
|
|
26
|
+
assert a.expires == ''
|
|
27
|
+
assert a.supersedes == ''
|
|
28
|
+
assert a.priorities == []
|
|
29
|
+
assert a.blocked == []
|
|
30
|
+
assert a.agent_actionable == []
|
|
31
|
+
assert a.context == ''
|
|
32
|
+
assert a.body == ''
|
|
33
|
+
assert a.source_path is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_focus_artifact_full():
|
|
37
|
+
a = FocusArtifact(
|
|
38
|
+
focus_date='2026-04-01',
|
|
39
|
+
expires='2026-04-07',
|
|
40
|
+
supersedes='2026-03-25',
|
|
41
|
+
priorities=[{'id': 'ws-1', 'reason': 'Demo Wednesday'}],
|
|
42
|
+
blocked=[{'id': 'ws-2', 'on': 'API access'}],
|
|
43
|
+
agent_actionable=[{'id': 'ws-3', 'action': 'Needs /plan'}],
|
|
44
|
+
context='Demo day is Wednesday.',
|
|
45
|
+
body='## Focus\n\nPolish the demo.',
|
|
46
|
+
)
|
|
47
|
+
assert a.date == '2026-04-01'
|
|
48
|
+
assert a.expires == '2026-04-07'
|
|
49
|
+
assert len(a.priorities) == 1
|
|
50
|
+
assert a.priorities[0]['reason'] == 'Demo Wednesday'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── frontmatter_dict ─────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def test_frontmatter_dict_minimal():
|
|
56
|
+
a = FocusArtifact(focus_date='2026-04-01')
|
|
57
|
+
d = a.frontmatter_dict()
|
|
58
|
+
assert d == {'date': '2026-04-01', 'type': 'focus'}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_frontmatter_dict_full():
|
|
62
|
+
a = FocusArtifact(
|
|
63
|
+
focus_date='2026-04-01',
|
|
64
|
+
expires='2026-04-07',
|
|
65
|
+
priorities=[{'id': 'ws-1', 'reason': 'urgent'}],
|
|
66
|
+
context='Big demo.',
|
|
67
|
+
)
|
|
68
|
+
d = a.frontmatter_dict()
|
|
69
|
+
assert d['expires'] == '2026-04-07'
|
|
70
|
+
assert d['priorities'] == [{'id': 'ws-1', 'reason': 'urgent'}]
|
|
71
|
+
assert d['context'] == 'Big demo.'
|
|
72
|
+
assert 'blocked' not in d # empty list omitted
|
|
73
|
+
assert 'supersedes' not in d # empty string omitted
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Round-trip: save + load ──────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def test_save_and_load_roundtrip(tmp_path: Path):
|
|
79
|
+
ws_dir = tmp_path / 'ws'
|
|
80
|
+
ws_dir.mkdir()
|
|
81
|
+
|
|
82
|
+
original = FocusArtifact(
|
|
83
|
+
focus_date='2026-04-01',
|
|
84
|
+
expires='2026-04-07',
|
|
85
|
+
supersedes='2026-03-25',
|
|
86
|
+
priorities=[{'id': 'ws-1', 'reason': 'Demo'}],
|
|
87
|
+
blocked=[{'id': 'ws-2', 'on': 'Waiting for key'}],
|
|
88
|
+
agent_actionable=[{'id': 'ws-3', 'action': 'Plan session'}],
|
|
89
|
+
context='Demo day.',
|
|
90
|
+
body='## Focus\n\nShip it.',
|
|
91
|
+
)
|
|
92
|
+
path = save_focus(original, ws_dir)
|
|
93
|
+
assert path.exists()
|
|
94
|
+
assert path.name == '2026-04-01.md'
|
|
95
|
+
|
|
96
|
+
loaded = load_focus(path)
|
|
97
|
+
assert loaded.date == '2026-04-01'
|
|
98
|
+
assert loaded.type == 'focus'
|
|
99
|
+
assert loaded.expires == '2026-04-07'
|
|
100
|
+
assert loaded.supersedes == '2026-03-25'
|
|
101
|
+
assert loaded.priorities == [{'id': 'ws-1', 'reason': 'Demo'}]
|
|
102
|
+
assert loaded.blocked == [{'id': 'ws-2', 'on': 'Waiting for key'}]
|
|
103
|
+
assert loaded.agent_actionable == [{'id': 'ws-3', 'action': 'Plan session'}]
|
|
104
|
+
assert loaded.context == 'Demo day.'
|
|
105
|
+
assert '## Focus' in loaded.body
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── focus_dir ────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def test_focus_dir_creates(tmp_path: Path):
|
|
111
|
+
ws_dir = tmp_path / 'ws'
|
|
112
|
+
ws_dir.mkdir()
|
|
113
|
+
fdir = focus_dir(ws_dir)
|
|
114
|
+
assert fdir.is_dir()
|
|
115
|
+
assert fdir == ws_dir / 'focus'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── load_latest_focus ────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def test_load_latest_focus_empty(tmp_path: Path):
|
|
121
|
+
ws_dir = tmp_path / 'ws'
|
|
122
|
+
ws_dir.mkdir()
|
|
123
|
+
assert load_latest_focus(ws_dir) is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_load_latest_focus_picks_newest(tmp_path: Path):
|
|
127
|
+
ws_dir = tmp_path / 'ws'
|
|
128
|
+
ws_dir.mkdir()
|
|
129
|
+
|
|
130
|
+
save_focus(FocusArtifact(focus_date='2026-03-25', body='old'), ws_dir)
|
|
131
|
+
save_focus(FocusArtifact(focus_date='2026-04-01', body='new'), ws_dir)
|
|
132
|
+
|
|
133
|
+
latest = load_latest_focus(ws_dir)
|
|
134
|
+
assert latest is not None
|
|
135
|
+
assert latest.date == '2026-04-01'
|
|
136
|
+
assert latest.body == 'new'
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── load_current_week_focus ──────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def test_current_week_focus_empty(tmp_path: Path):
|
|
142
|
+
ws_dir = tmp_path / 'ws'
|
|
143
|
+
ws_dir.mkdir()
|
|
144
|
+
assert load_current_week_focus(ws_dir) is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_current_week_focus_finds_this_week(tmp_path: Path):
|
|
148
|
+
ws_dir = tmp_path / 'ws'
|
|
149
|
+
ws_dir.mkdir()
|
|
150
|
+
|
|
151
|
+
today = date.today()
|
|
152
|
+
today_str = today.isoformat()
|
|
153
|
+
# Doc created today, expires end of week
|
|
154
|
+
monday = today - timedelta(days=today.weekday())
|
|
155
|
+
expires = (monday + timedelta(days=6)).isoformat()
|
|
156
|
+
|
|
157
|
+
save_focus(FocusArtifact(
|
|
158
|
+
focus_date=today_str,
|
|
159
|
+
expires=expires,
|
|
160
|
+
body='this week',
|
|
161
|
+
), ws_dir)
|
|
162
|
+
|
|
163
|
+
found = load_current_week_focus(ws_dir)
|
|
164
|
+
assert found is not None
|
|
165
|
+
assert found.date == today_str
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_current_week_focus_skips_expired(tmp_path: Path):
|
|
169
|
+
ws_dir = tmp_path / 'ws'
|
|
170
|
+
ws_dir.mkdir()
|
|
171
|
+
|
|
172
|
+
today = date.today()
|
|
173
|
+
# Doc from today but expired yesterday
|
|
174
|
+
yesterday = (today - timedelta(days=1)).isoformat()
|
|
175
|
+
save_focus(FocusArtifact(
|
|
176
|
+
focus_date=today.isoformat(),
|
|
177
|
+
expires=yesterday,
|
|
178
|
+
body='expired',
|
|
179
|
+
), ws_dir)
|
|
180
|
+
|
|
181
|
+
assert load_current_week_focus(ws_dir) is None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_current_week_focus_skips_last_week(tmp_path: Path):
|
|
185
|
+
ws_dir = tmp_path / 'ws'
|
|
186
|
+
ws_dir.mkdir()
|
|
187
|
+
|
|
188
|
+
today = date.today()
|
|
189
|
+
last_week = (today - timedelta(days=8)).isoformat()
|
|
190
|
+
save_focus(FocusArtifact(
|
|
191
|
+
focus_date=last_week,
|
|
192
|
+
body='old week',
|
|
193
|
+
), ws_dir)
|
|
194
|
+
|
|
195
|
+
assert load_current_week_focus(ws_dir) is None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_current_week_focus_no_expiry_is_valid(tmp_path: Path):
|
|
199
|
+
"""A focus doc with no expires field never expires."""
|
|
200
|
+
ws_dir = tmp_path / 'ws'
|
|
201
|
+
ws_dir.mkdir()
|
|
202
|
+
|
|
203
|
+
today = date.today().isoformat()
|
|
204
|
+
save_focus(FocusArtifact(
|
|
205
|
+
focus_date=today,
|
|
206
|
+
body='no expiry',
|
|
207
|
+
), ws_dir)
|
|
208
|
+
|
|
209
|
+
found = load_current_week_focus(ws_dir)
|
|
210
|
+
assert found is not None
|
|
211
|
+
assert found.body == 'no expiry'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Tests for workstream.git helpers."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from workstream.git import git_add, git_commit, modified_files
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_modified_files_empty_repo(tmp_path: Path):
|
|
10
|
+
"""Clean repo returns no modified files."""
|
|
11
|
+
subprocess.run(['git', 'init'], cwd=tmp_path, capture_output=True)
|
|
12
|
+
subprocess.run(['git', 'config', 'user.email', 'test@test.com'], cwd=tmp_path, capture_output=True)
|
|
13
|
+
subprocess.run(['git', 'config', 'user.name', 'Test'], cwd=tmp_path, capture_output=True)
|
|
14
|
+
# Create and commit a file so the repo isn't empty
|
|
15
|
+
(tmp_path / 'readme.md').write_text('hello')
|
|
16
|
+
subprocess.run(['git', 'add', '.'], cwd=tmp_path, capture_output=True)
|
|
17
|
+
subprocess.run(['git', 'commit', '-m', 'init'], cwd=tmp_path, capture_output=True)
|
|
18
|
+
assert modified_files(tmp_path) == []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_modified_files_with_changes(tmp_path: Path):
|
|
22
|
+
"""Modified and untracked files are returned."""
|
|
23
|
+
subprocess.run(['git', 'init'], cwd=tmp_path, capture_output=True)
|
|
24
|
+
subprocess.run(['git', 'config', 'user.email', 'test@test.com'], cwd=tmp_path, capture_output=True)
|
|
25
|
+
subprocess.run(['git', 'config', 'user.name', 'Test'], cwd=tmp_path, capture_output=True)
|
|
26
|
+
(tmp_path / 'tracked.md').write_text('original')
|
|
27
|
+
subprocess.run(['git', 'add', '.'], cwd=tmp_path, capture_output=True)
|
|
28
|
+
subprocess.run(['git', 'commit', '-m', 'init'], cwd=tmp_path, capture_output=True)
|
|
29
|
+
|
|
30
|
+
# Modify tracked file and create untracked file
|
|
31
|
+
(tmp_path / 'tracked.md').write_text('modified')
|
|
32
|
+
(tmp_path / 'new.md').write_text('untracked')
|
|
33
|
+
|
|
34
|
+
result = modified_files(tmp_path)
|
|
35
|
+
assert 'tracked.md' in result
|
|
36
|
+
assert 'new.md' in result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_modified_files_non_repo(tmp_path: Path):
|
|
40
|
+
"""Non-git directory returns empty list."""
|
|
41
|
+
assert modified_files(tmp_path) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _init_repo(path: Path) -> None:
|
|
46
|
+
"""Initialize a git repo with user config and an initial commit."""
|
|
47
|
+
subprocess.run(['git', 'init'], cwd=path, capture_output=True)
|
|
48
|
+
subprocess.run(['git', 'config', 'user.email', 'test@test.com'], cwd=path, capture_output=True)
|
|
49
|
+
subprocess.run(['git', 'config', 'user.name', 'Test'], cwd=path, capture_output=True)
|
|
50
|
+
(path / 'readme.md').write_text('init')
|
|
51
|
+
subprocess.run(['git', 'add', '.'], cwd=path, capture_output=True)
|
|
52
|
+
subprocess.run(['git', 'commit', '-m', 'init'], cwd=path, capture_output=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_git_add_stages_files(tmp_path: Path):
|
|
56
|
+
"""git_add stages specified files."""
|
|
57
|
+
_init_repo(tmp_path)
|
|
58
|
+
(tmp_path / 'a.md').write_text('content a')
|
|
59
|
+
(tmp_path / 'b.md').write_text('content b')
|
|
60
|
+
assert git_add(tmp_path, [tmp_path / 'a.md', tmp_path / 'b.md'])
|
|
61
|
+
# Verify staged
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
['git', 'diff', '--cached', '--name-only'],
|
|
64
|
+
cwd=tmp_path, capture_output=True, text=True,
|
|
65
|
+
)
|
|
66
|
+
staged = result.stdout.strip().splitlines()
|
|
67
|
+
assert 'a.md' in staged
|
|
68
|
+
assert 'b.md' in staged
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_git_commit_creates_commit(tmp_path: Path):
|
|
72
|
+
"""git_commit creates a commit from staged changes."""
|
|
73
|
+
_init_repo(tmp_path)
|
|
74
|
+
(tmp_path / 'note.md').write_text('content')
|
|
75
|
+
git_add(tmp_path, [tmp_path / 'note.md'])
|
|
76
|
+
assert git_commit(tmp_path, 'notes: 2026-03-31')
|
|
77
|
+
# Verify commit message
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
['git', 'log', '-1', '--format=%s'],
|
|
80
|
+
cwd=tmp_path, capture_output=True, text=True,
|
|
81
|
+
)
|
|
82
|
+
assert result.stdout.strip() == 'notes: 2026-03-31'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_git_commit_nothing_staged(tmp_path: Path):
|
|
86
|
+
"""git_commit with nothing staged returns False."""
|
|
87
|
+
_init_repo(tmp_path)
|
|
88
|
+
assert git_commit(tmp_path, 'empty') is False
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tests for heuristic helpers: branch_is_merged, PlanRef.signal, find_duplicate_repo_names, resolve_repo_path."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from workstream.config import Config
|
|
7
|
+
from workstream.markdown import _render_plan_line, parse_plans, set_section
|
|
8
|
+
from workstream.models import PlanRef
|
|
9
|
+
from workstream.repo_discovery import find_duplicate_repo_names
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# PlanRef.signal
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_plan_ref_signal_default():
|
|
18
|
+
ref = PlanRef(repo='myapp', path='plan.md', status='active', title='Test', date='2026-03-30')
|
|
19
|
+
assert ref.signal == ''
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_plan_ref_signal_set():
|
|
23
|
+
ref = PlanRef(repo='myapp', path='plan.md', status='active', title='Test', date='2026-03-30', signal='likely-implemented')
|
|
24
|
+
assert ref.signal == 'likely-implemented'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_plan_render_with_signal():
|
|
28
|
+
ref = PlanRef(repo='myapp', path='plan.md', status='active', title='Test', date='2026-03-30', signal='stale')
|
|
29
|
+
line = _render_plan_line(ref)
|
|
30
|
+
assert '[stale]' in line
|
|
31
|
+
assert line.startswith('- myapp')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_plan_render_without_signal():
|
|
35
|
+
ref = PlanRef(repo='myapp', path='plan.md', status='active', title='Test', date='2026-03-30')
|
|
36
|
+
line = _render_plan_line(ref)
|
|
37
|
+
assert '[' not in line
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_plan_parse_roundtrip_with_signal():
|
|
41
|
+
ref = PlanRef(repo='myapp', path='plan.md', status='active', title='Test', date='2026-03-30', signal='stale')
|
|
42
|
+
line = _render_plan_line(ref)
|
|
43
|
+
body = set_section('', 'Plans', '<!-- Updated by ws sweep -->\n' + line)
|
|
44
|
+
parsed = parse_plans(body)
|
|
45
|
+
assert len(parsed) == 1
|
|
46
|
+
assert parsed[0].signal == 'stale'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# find_duplicate_repo_names
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_find_duplicate_repo_names_none(tmp_path: Path):
|
|
55
|
+
parent = tmp_path / 'repos'
|
|
56
|
+
(parent / 'repo1' / '.git').mkdir(parents=True)
|
|
57
|
+
(parent / 'repo2' / '.git').mkdir(parents=True)
|
|
58
|
+
result = find_duplicate_repo_names([str(parent)])
|
|
59
|
+
assert result == {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_find_duplicate_repo_names_found(tmp_path: Path):
|
|
63
|
+
parent1 = tmp_path / 'dir1'
|
|
64
|
+
parent2 = tmp_path / 'dir2'
|
|
65
|
+
(parent1 / 'myrepo' / '.git').mkdir(parents=True)
|
|
66
|
+
(parent2 / 'myrepo' / '.git').mkdir(parents=True)
|
|
67
|
+
result = find_duplicate_repo_names([str(parent1), str(parent2)])
|
|
68
|
+
assert 'myrepo' in result
|
|
69
|
+
assert len(result['myrepo']) == 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# resolve_repo_path duplicate warning
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_resolve_repo_path_warns_on_duplicate(tmp_path: Path, capsys):
|
|
78
|
+
parent1 = tmp_path / 'dir1'
|
|
79
|
+
parent2 = tmp_path / 'dir2'
|
|
80
|
+
(parent1 / 'myrepo' / '.git').mkdir(parents=True)
|
|
81
|
+
(parent2 / 'myrepo' / '.git').mkdir(parents=True)
|
|
82
|
+
config = Config(repo_dirs=[str(parent1), str(parent2)])
|
|
83
|
+
result = config.resolve_repo_path('myrepo')
|
|
84
|
+
assert result is not None
|
|
85
|
+
captured = capsys.readouterr()
|
|
86
|
+
assert 'warning' in captured.err
|
|
87
|
+
assert 'myrepo' in captured.err
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# branch_is_merged (real temporary git repos)
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _init_git_repo(path: Path) -> None:
|
|
96
|
+
"""Create a git repo at *path* with one initial commit on main."""
|
|
97
|
+
path.mkdir(exist_ok=True)
|
|
98
|
+
subprocess.run(['git', 'init', '-b', 'main'], cwd=path, capture_output=True, check=True)
|
|
99
|
+
subprocess.run(['git', 'config', 'user.email', 'test@test.com'], cwd=path, capture_output=True, check=True)
|
|
100
|
+
subprocess.run(['git', 'config', 'user.name', 'Test'], cwd=path, capture_output=True, check=True)
|
|
101
|
+
(path / 'file.txt').write_text('initial')
|
|
102
|
+
subprocess.run(['git', 'add', '.'], cwd=path, capture_output=True, check=True)
|
|
103
|
+
subprocess.run(['git', 'commit', '-m', 'init'], cwd=path, capture_output=True, check=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_branch_is_merged_in_real_repo(tmp_path: Path):
|
|
107
|
+
from workstream.git import branch_is_merged
|
|
108
|
+
|
|
109
|
+
repo = tmp_path / 'repo'
|
|
110
|
+
_init_git_repo(repo)
|
|
111
|
+
|
|
112
|
+
# Create and merge a feature branch
|
|
113
|
+
subprocess.run(['git', 'checkout', '-b', 'feature'], cwd=repo, capture_output=True, check=True)
|
|
114
|
+
(repo / 'feature.txt').write_text('feature')
|
|
115
|
+
subprocess.run(['git', 'add', '.'], cwd=repo, capture_output=True, check=True)
|
|
116
|
+
subprocess.run(['git', 'commit', '-m', 'feature'], cwd=repo, capture_output=True, check=True)
|
|
117
|
+
subprocess.run(['git', 'checkout', 'main'], cwd=repo, capture_output=True, check=True)
|
|
118
|
+
subprocess.run(['git', 'merge', 'feature'], cwd=repo, capture_output=True, check=True)
|
|
119
|
+
|
|
120
|
+
assert branch_is_merged(repo, 'feature', 'main') is True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_branch_is_merged_not_merged(tmp_path: Path):
|
|
124
|
+
from workstream.git import branch_is_merged
|
|
125
|
+
|
|
126
|
+
repo = tmp_path / 'repo'
|
|
127
|
+
_init_git_repo(repo)
|
|
128
|
+
|
|
129
|
+
# Feature branch with commit not merged into main
|
|
130
|
+
subprocess.run(['git', 'checkout', '-b', 'feature'], cwd=repo, capture_output=True, check=True)
|
|
131
|
+
(repo / 'feature.txt').write_text('feature')
|
|
132
|
+
subprocess.run(['git', 'add', '.'], cwd=repo, capture_output=True, check=True)
|
|
133
|
+
subprocess.run(['git', 'commit', '-m', 'feature'], cwd=repo, capture_output=True, check=True)
|
|
134
|
+
subprocess.run(['git', 'checkout', 'main'], cwd=repo, capture_output=True, check=True)
|
|
135
|
+
|
|
136
|
+
assert branch_is_merged(repo, 'feature', 'main') is False
|