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.
Files changed (86) hide show
  1. workstream/ARCHITECTURE.md +89 -0
  2. workstream/__init__.py +8 -0
  3. workstream/cli.py +136 -0
  4. workstream/commands/__init__.py +0 -0
  5. workstream/commands/backfill.py +139 -0
  6. workstream/commands/block.py +93 -0
  7. workstream/commands/checkin.py +51 -0
  8. workstream/commands/cron.py +119 -0
  9. workstream/commands/focus_cmd.py +273 -0
  10. workstream/commands/idea.py +172 -0
  11. workstream/commands/index.py +89 -0
  12. workstream/commands/init.py +567 -0
  13. workstream/commands/inspect_cmd.py +354 -0
  14. workstream/commands/list_cmd.py +99 -0
  15. workstream/commands/nest.py +108 -0
  16. workstream/commands/new.py +95 -0
  17. workstream/commands/next_cmd.py +333 -0
  18. workstream/commands/report.py +190 -0
  19. workstream/commands/resume.py +145 -0
  20. workstream/commands/review.py +227 -0
  21. workstream/commands/serve.py +23 -0
  22. workstream/commands/setup.py +178 -0
  23. workstream/commands/show.py +123 -0
  24. workstream/commands/snooze.py +117 -0
  25. workstream/commands/stale.py +116 -0
  26. workstream/commands/sweep.py +1753 -0
  27. workstream/commands/tree.py +105 -0
  28. workstream/commands/update_status.py +117 -0
  29. workstream/config.py +322 -0
  30. workstream/extensions/__init__.py +0 -0
  31. workstream/extensions/workstream.ts +633 -0
  32. workstream/focus_artifact.py +157 -0
  33. workstream/git.py +194 -0
  34. workstream/harness.py +49 -0
  35. workstream/llm.py +78 -0
  36. workstream/markdown.py +501 -0
  37. workstream/models.py +274 -0
  38. workstream/plan_index.py +88 -0
  39. workstream/provisioning.py +196 -0
  40. workstream/repo_discovery.py +158 -0
  41. workstream/review_artifact.py +96 -0
  42. workstream/scripts/migrate_statuses.py +120 -0
  43. workstream/skills/__init__.py +0 -0
  44. workstream/skills/workstream_context/SKILL.md +75 -0
  45. workstream/skills/workstream_context/__init__.py +0 -0
  46. workstream/skills/workstream_focus/SKILL.md +141 -0
  47. workstream/skills/workstream_init/SKILL.md +86 -0
  48. workstream/skills/workstream_review/SKILL.md +224 -0
  49. workstream/skills/workstream_sweep/SKILL.md +178 -0
  50. workstream/sweep_state.py +93 -0
  51. workstream/templates/dashboard.html +382 -0
  52. workstream/templates/detail.html +360 -0
  53. workstream/templates/plan.html +210 -0
  54. workstream/test/__init__.py +0 -0
  55. workstream/test/conftest.py +221 -0
  56. workstream/test/fixtures/sample_sprint_note.md +10 -0
  57. workstream/test/fixtures/sample_workstream.md +41 -0
  58. workstream/test/test_backfill.py +180 -0
  59. workstream/test/test_batch_writeback.py +81 -0
  60. workstream/test/test_commands.py +938 -0
  61. workstream/test/test_config.py +54 -0
  62. workstream/test/test_focus_artifact.py +211 -0
  63. workstream/test/test_git.py +88 -0
  64. workstream/test/test_heuristics.py +136 -0
  65. workstream/test/test_hierarchy.py +231 -0
  66. workstream/test/test_init.py +452 -0
  67. workstream/test/test_inspect.py +143 -0
  68. workstream/test/test_llm.py +78 -0
  69. workstream/test/test_markdown.py +626 -0
  70. workstream/test/test_models.py +506 -0
  71. workstream/test/test_next.py +206 -0
  72. workstream/test/test_plan_index.py +83 -0
  73. workstream/test/test_provisioning.py +270 -0
  74. workstream/test/test_repo_discovery.py +181 -0
  75. workstream/test/test_resume.py +71 -0
  76. workstream/test/test_sweep.py +1196 -0
  77. workstream/test/test_sweep_state.py +86 -0
  78. workstream/test/test_thoughts.py +516 -0
  79. workstream/test/test_web.py +606 -0
  80. workstream/thoughts.py +505 -0
  81. workstream/web.py +444 -0
  82. workstream_cli-0.0.1.dist-info/LICENSE +21 -0
  83. workstream_cli-0.0.1.dist-info/METADATA +93 -0
  84. workstream_cli-0.0.1.dist-info/RECORD +86 -0
  85. workstream_cli-0.0.1.dist-info/WHEEL +4 -0
  86. 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