continuous-refactoring 0.1.0__tar.gz → 0.2.0__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 (68) hide show
  1. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/PKG-INFO +32 -4
  2. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/README.md +31 -3
  3. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/pyproject.toml +1 -1
  4. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/cli.py +182 -9
  5. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/config.py +33 -1
  6. continuous_refactoring-0.2.0/tests/test_cli_init_taste.py +695 -0
  7. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_cli_taste_warning.py +57 -1
  8. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_config.py +168 -1
  9. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_taste_interview.py +31 -0
  10. continuous_refactoring-0.1.0/tests/test_cli_init_taste.py +0 -288
  11. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/.gitignore +0 -0
  12. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/LICENSE +0 -0
  13. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/__init__.py +0 -0
  14. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/__main__.py +0 -0
  15. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/agent.py +0 -0
  16. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/artifacts.py +0 -0
  17. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/commit_messages.py +0 -0
  18. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/decisions.py +0 -0
  19. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/effort.py +0 -0
  20. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/failure_report.py +0 -0
  21. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/git.py +0 -0
  22. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/loop.py +0 -0
  23. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/migration_manifest_codec.py +0 -0
  24. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/migration_tick.py +0 -0
  25. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/migrations.py +0 -0
  26. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/phases.py +0 -0
  27. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/planning.py +0 -0
  28. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/prompts.py +0 -0
  29. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/refactor_attempts.py +0 -0
  30. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/review_cli.py +0 -0
  31. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/routing.py +0 -0
  32. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/routing_pipeline.py +0 -0
  33. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/scope_candidates.py +0 -0
  34. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/scope_expansion.py +0 -0
  35. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/src/continuous_refactoring/targeting.py +0 -0
  36. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/conftest.py +0 -0
  37. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/fixtures/claude_stream_json/selection.stdout.log +0 -0
  38. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_claude_stream_json.py +0 -0
  39. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_cli_review.py +0 -0
  40. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_cli_upgrade.py +0 -0
  41. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_commit_messages.py +0 -0
  42. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_continuous_refactoring.py +0 -0
  43. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_decisions.py +0 -0
  44. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_e2e.py +0 -0
  45. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_effort.py +0 -0
  46. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_failure_report.py +0 -0
  47. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_focus_on_live_migrations.py +0 -0
  48. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_git.py +0 -0
  49. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_loop_migration_tick.py +0 -0
  50. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_main_entrypoint.py +0 -0
  51. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_migrations.py +0 -0
  52. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_no_driver_branching.py +0 -0
  53. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_phases.py +0 -0
  54. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_planning.py +0 -0
  55. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_prompts.py +0 -0
  56. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_prompts_scope_selection.py +0 -0
  57. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_routing.py +0 -0
  58. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_run.py +0 -0
  59. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_run_once.py +0 -0
  60. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_run_once_regression.py +0 -0
  61. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_scope_candidates.py +0 -0
  62. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_scope_expansion.py +0 -0
  63. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_scope_loop_integration.py +0 -0
  64. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_scope_selection.py +0 -0
  65. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_targeting.py +0 -0
  66. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_taste_refine.py +0 -0
  67. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_taste_upgrade.py +0 -0
  68. {continuous_refactoring-0.1.0 → continuous_refactoring-0.2.0}/tests/test_wake_up.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: continuous-refactoring
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Continuous refactoring loop for AI coding agents
5
5
  Project-URL: Repository, https://github.com/bigH/continuous-refactoring
6
6
  Project-URL: Issues, https://github.com/bigH/continuous-refactoring/issues
@@ -23,16 +23,41 @@ Description-Content-Type: text/markdown
23
23
 
24
24
  # continuous-refactoring
25
25
 
26
+ [![GitHub repo](https://img.shields.io/badge/github-repo-green)](https://github.com/bigH/continuous-refactoring)
27
+ [![PyPI](https://img.shields.io/pypi/v/continuous-refactoring.svg)](https://pypi.org/project/continuous-refactoring/)
28
+ [![Tests](https://github.com/bigH/continuous-refactoring/actions/workflows/test.yml/badge.svg)](https://github.com/bigH/continuous-refactoring/actions/workflows/test.yml)
29
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/bigH/continuous-refactoring/blob/main/LICENSE)
30
+
26
31
  Small, test-gated cleanup commits by an AI coding agent.
27
32
 
28
33
  Think of it as a supervised janitor loop: the agent proposes a cleanup, your tests decide if it stays.
29
34
 
30
35
  ## Install
31
36
 
37
+ Try it without installing:
38
+
39
+ ```bash
40
+ uvx continuous-refactoring --help
41
+ ```
42
+
43
+ Or install it with [uv](https://docs.astral.sh/uv/guides/tools/):
44
+
32
45
  ```bash
33
46
  uv tool install continuous-refactoring
34
47
  ```
35
48
 
49
+ Or with [pipx](https://pypa.github.io/pipx/):
50
+
51
+ ```bash
52
+ pipx install continuous-refactoring
53
+ ```
54
+
55
+ Or with pip:
56
+
57
+ ```bash
58
+ pip install continuous-refactoring
59
+ ```
60
+
36
61
  For a checkout:
37
62
 
38
63
  ```bash
@@ -89,6 +114,8 @@ That keeps sweeping targets until it runs out, hits your caps, or starts failing
89
114
  ```bash
90
115
  # 1. Register the repo (creates a project dir under ~/.local/share/continuous-refactoring)
91
116
  continuous-refactoring init
117
+ # Or keep project taste in the repo:
118
+ continuous-refactoring init --in-repo-taste
92
119
 
93
120
  # 2. (Optional) Write your refactoring taste — either edit the file, have an agent interview you,
94
121
  # or refine an existing draft collaboratively
@@ -113,7 +140,7 @@ continuous-refactoring run \
113
140
 
114
141
  | Command | What it does |
115
142
  |---|---|
116
- | `init` | Registers this directory as a project, creates a default `taste.md`, and can store `--live-migrations-dir`. |
143
+ | `init` | Registers this directory as a project, creates a default `taste.md`, and can store `--live-migrations-dir` or `--in-repo-taste`. |
117
144
  | `taste` | Prints the active taste file path. Add `--interview` to have an agent author it, `--refine` to iteratively improve an existing taste doc, `--upgrade` to refresh stale taste dimensions, `--global` for the shared file, and `--force` to let `--interview` overwrite custom content after writing a `.bak`. |
118
145
  | `run-once` | Single pass on one resolved target. No retry. If there is a diff and validation passes, it commits locally and prints the diffstat. |
119
146
  | `run` | The loop. Iterates targets, retries on failure, and commits successful targets locally. |
@@ -141,6 +168,7 @@ If you provide none of `--targets`, `--globs`, `--extensions`, or `--paths`, the
141
168
  ### Migrations & taste flags
142
169
 
143
170
  - `init --live-migrations-dir PATH` — enables the larger-refactoring workflow for this project. The path is stored repo-relative in the project registry and created if missing.
171
+ - `init --in-repo-taste [PATH]` — stores this project's taste file in the repo and remembers the repo-relative path. Defaults to `.continuous-refactoring/taste.md`; re-run `init --in-repo-taste ...` to choose a different path.
144
172
  - `taste --refine` — opens a collaborative editing session for the taste file. The agent keeps refining until you tell it to write, then the session ends automatically after the settled write.
145
173
  - `taste --upgrade` — re-interviews for taste dimensions added since your last version. No-op when already current; use `taste --refine` if you want to rework the doc anyway.
146
174
  - `taste --force` — only applies to `--interview`; it allows a customized taste file to be overwritten after backing it up to `taste.md.bak`.
@@ -191,10 +219,10 @@ The path prints at startup. Grep it when something goes sideways.
191
219
 
192
220
  The taste file is a short bullet list of your refactoring preferences. It gets injected into every agent prompt.
193
221
 
194
- - Project taste: `~/.local/share/continuous-refactoring/projects/<uuid>/taste.md`
222
+ - Project taste: `~/.local/share/continuous-refactoring/projects/<uuid>/taste.md`, or the repo-local path chosen with `init --in-repo-taste [PATH]`
195
223
  - Global taste: `~/.local/share/continuous-refactoring/global/taste.md`
196
224
 
197
- Project taste wins over global. Use `taste --interview` to bootstrap one, `taste --refine` to rework it with an agent, or edit the file directly any time.
225
+ Project taste wins over global. Use `taste` to print the active path, `taste --interview` to bootstrap one, `taste --refine` to rework it with an agent, or edit the file directly any time.
198
226
 
199
227
  ## Larger refactorings
200
228
 
@@ -1,15 +1,40 @@
1
1
  # continuous-refactoring
2
2
 
3
+ [![GitHub repo](https://img.shields.io/badge/github-repo-green)](https://github.com/bigH/continuous-refactoring)
4
+ [![PyPI](https://img.shields.io/pypi/v/continuous-refactoring.svg)](https://pypi.org/project/continuous-refactoring/)
5
+ [![Tests](https://github.com/bigH/continuous-refactoring/actions/workflows/test.yml/badge.svg)](https://github.com/bigH/continuous-refactoring/actions/workflows/test.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/bigH/continuous-refactoring/blob/main/LICENSE)
7
+
3
8
  Small, test-gated cleanup commits by an AI coding agent.
4
9
 
5
10
  Think of it as a supervised janitor loop: the agent proposes a cleanup, your tests decide if it stays.
6
11
 
7
12
  ## Install
8
13
 
14
+ Try it without installing:
15
+
16
+ ```bash
17
+ uvx continuous-refactoring --help
18
+ ```
19
+
20
+ Or install it with [uv](https://docs.astral.sh/uv/guides/tools/):
21
+
9
22
  ```bash
10
23
  uv tool install continuous-refactoring
11
24
  ```
12
25
 
26
+ Or with [pipx](https://pypa.github.io/pipx/):
27
+
28
+ ```bash
29
+ pipx install continuous-refactoring
30
+ ```
31
+
32
+ Or with pip:
33
+
34
+ ```bash
35
+ pip install continuous-refactoring
36
+ ```
37
+
13
38
  For a checkout:
14
39
 
15
40
  ```bash
@@ -66,6 +91,8 @@ That keeps sweeping targets until it runs out, hits your caps, or starts failing
66
91
  ```bash
67
92
  # 1. Register the repo (creates a project dir under ~/.local/share/continuous-refactoring)
68
93
  continuous-refactoring init
94
+ # Or keep project taste in the repo:
95
+ continuous-refactoring init --in-repo-taste
69
96
 
70
97
  # 2. (Optional) Write your refactoring taste — either edit the file, have an agent interview you,
71
98
  # or refine an existing draft collaboratively
@@ -90,7 +117,7 @@ continuous-refactoring run \
90
117
 
91
118
  | Command | What it does |
92
119
  |---|---|
93
- | `init` | Registers this directory as a project, creates a default `taste.md`, and can store `--live-migrations-dir`. |
120
+ | `init` | Registers this directory as a project, creates a default `taste.md`, and can store `--live-migrations-dir` or `--in-repo-taste`. |
94
121
  | `taste` | Prints the active taste file path. Add `--interview` to have an agent author it, `--refine` to iteratively improve an existing taste doc, `--upgrade` to refresh stale taste dimensions, `--global` for the shared file, and `--force` to let `--interview` overwrite custom content after writing a `.bak`. |
95
122
  | `run-once` | Single pass on one resolved target. No retry. If there is a diff and validation passes, it commits locally and prints the diffstat. |
96
123
  | `run` | The loop. Iterates targets, retries on failure, and commits successful targets locally. |
@@ -118,6 +145,7 @@ If you provide none of `--targets`, `--globs`, `--extensions`, or `--paths`, the
118
145
  ### Migrations & taste flags
119
146
 
120
147
  - `init --live-migrations-dir PATH` — enables the larger-refactoring workflow for this project. The path is stored repo-relative in the project registry and created if missing.
148
+ - `init --in-repo-taste [PATH]` — stores this project's taste file in the repo and remembers the repo-relative path. Defaults to `.continuous-refactoring/taste.md`; re-run `init --in-repo-taste ...` to choose a different path.
121
149
  - `taste --refine` — opens a collaborative editing session for the taste file. The agent keeps refining until you tell it to write, then the session ends automatically after the settled write.
122
150
  - `taste --upgrade` — re-interviews for taste dimensions added since your last version. No-op when already current; use `taste --refine` if you want to rework the doc anyway.
123
151
  - `taste --force` — only applies to `--interview`; it allows a customized taste file to be overwritten after backing it up to `taste.md.bak`.
@@ -168,10 +196,10 @@ The path prints at startup. Grep it when something goes sideways.
168
196
 
169
197
  The taste file is a short bullet list of your refactoring preferences. It gets injected into every agent prompt.
170
198
 
171
- - Project taste: `~/.local/share/continuous-refactoring/projects/<uuid>/taste.md`
199
+ - Project taste: `~/.local/share/continuous-refactoring/projects/<uuid>/taste.md`, or the repo-local path chosen with `init --in-repo-taste [PATH]`
172
200
  - Global taste: `~/.local/share/continuous-refactoring/global/taste.md`
173
201
 
174
- Project taste wins over global. Use `taste --interview` to bootstrap one, `taste --refine` to rework it with an agent, or edit the file directly any time.
202
+ Project taste wins over global. Use `taste` to print the active path, `taste --interview` to bootstrap one, `taste --refine` to rework it with an agent, or edit the file directly any time.
175
203
 
176
204
  ## Larger refactorings
177
205
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "continuous-refactoring"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Continuous refactoring loop for AI coding agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import shutil
4
5
  import sys
6
+ import uuid
5
7
  from collections.abc import Callable
6
8
  from pathlib import Path
7
9
 
@@ -102,6 +104,8 @@ def _add_common_args(parser: argparse.ArgumentParser) -> None:
102
104
 
103
105
 
104
106
  def _add_init_parser(subparsers: argparse._SubParsersAction) -> None:
107
+ from continuous_refactoring.config import DEFAULT_REPO_TASTE_PATH
108
+
105
109
  init_parser = subparsers.add_parser(
106
110
  "init",
107
111
  help="Register a project for continuous refactoring.",
@@ -119,6 +123,23 @@ def _add_init_parser(subparsers: argparse._SubParsersAction) -> None:
119
123
  default=None,
120
124
  help="Directory for live migration artifacts (repo-relative path).",
121
125
  )
126
+ init_parser.add_argument(
127
+ "--in-repo-taste",
128
+ type=Path,
129
+ nargs="?",
130
+ const=Path(DEFAULT_REPO_TASTE_PATH),
131
+ default=None,
132
+ metavar="PATH",
133
+ help=(
134
+ "Store this project's taste file in the repo. "
135
+ f"Defaults to {DEFAULT_REPO_TASTE_PATH}."
136
+ ),
137
+ )
138
+ init_parser.add_argument(
139
+ "--force",
140
+ action="store_true",
141
+ help="Replace destination state when reconfiguring taste or live migrations.",
142
+ )
122
143
 
123
144
 
124
145
  def _add_taste_parser(subparsers: argparse._SubParsersAction) -> None:
@@ -273,16 +294,39 @@ def _handle_init(args: argparse.Namespace) -> None:
273
294
  from continuous_refactoring.config import (
274
295
  ensure_taste_file,
275
296
  register_project,
297
+ resolve_live_migrations_dir,
298
+ resolve_project,
299
+ resolve_project_taste_path,
276
300
  set_live_migrations_dir,
301
+ set_repo_taste_path,
277
302
  )
278
303
 
279
304
  path = (args.path or Path.cwd()).resolve()
305
+ in_repo_taste_arg: Path | None = getattr(args, "in_repo_taste", None)
306
+ live_dir_arg: Path | None = getattr(args, "live_migrations_dir", None)
307
+ force = bool(getattr(args, "force", False))
308
+ repo_taste_relative: str | None = None
309
+ repo_taste_resolved: Path | None = None
310
+ resolved_live: Path | None = None
311
+ live_dir_relative: str | None = None
312
+
280
313
  try:
281
- project = register_project(path)
282
- taste_path = project.project_dir / "taste.md"
283
- ensure_taste_file(taste_path)
314
+ if in_repo_taste_arg is not None:
315
+ repo_taste_resolved = (path / in_repo_taste_arg).resolve()
316
+ if not repo_taste_resolved.is_relative_to(path):
317
+ print(
318
+ f"Error: --in-repo-taste must be inside the repo: {in_repo_taste_arg}",
319
+ file=sys.stderr,
320
+ )
321
+ raise SystemExit(2)
322
+ if repo_taste_resolved.exists() and not repo_taste_resolved.is_file():
323
+ print(
324
+ f"Error: --in-repo-taste must point to a file: {in_repo_taste_arg}",
325
+ file=sys.stderr,
326
+ )
327
+ raise SystemExit(2)
328
+ repo_taste_relative = str(repo_taste_resolved.relative_to(path))
284
329
 
285
- live_dir_arg: Path | None = getattr(args, "live_migrations_dir", None)
286
330
  if live_dir_arg is not None:
287
331
  resolved_live = (path / live_dir_arg).resolve()
288
332
  if not resolved_live.is_relative_to(path):
@@ -291,9 +335,39 @@ def _handle_init(args: argparse.Namespace) -> None:
291
335
  file=sys.stderr,
292
336
  )
293
337
  raise SystemExit(2)
294
- relative = str(resolved_live.relative_to(path))
295
- resolved_live.mkdir(parents=True, exist_ok=True)
296
- set_live_migrations_dir(project.entry.uuid, relative)
338
+ if resolved_live.exists() and not resolved_live.is_dir():
339
+ print(
340
+ f"Error: --live-migrations-dir must point to a directory: {live_dir_arg}",
341
+ file=sys.stderr,
342
+ )
343
+ raise SystemExit(2)
344
+ live_dir_relative = str(resolved_live.relative_to(path))
345
+
346
+ project = register_project(path)
347
+ if repo_taste_relative is not None:
348
+ assert repo_taste_resolved is not None
349
+ _configure_repo_taste(
350
+ current=resolve_project_taste_path(project),
351
+ destination=repo_taste_resolved,
352
+ force=force,
353
+ ensure_taste_file=ensure_taste_file,
354
+ )
355
+ set_repo_taste_path(project.entry.uuid, repo_taste_relative)
356
+ project = resolve_project(path)
357
+
358
+ taste_path = resolve_project_taste_path(project)
359
+ ensure_taste_file(taste_path)
360
+
361
+ if live_dir_arg is not None:
362
+ assert resolved_live is not None
363
+ assert live_dir_relative is not None
364
+ _configure_live_migrations_dir(
365
+ current=resolve_live_migrations_dir(project),
366
+ destination=resolved_live,
367
+ force=force,
368
+ )
369
+ set_live_migrations_dir(project.entry.uuid, live_dir_relative)
370
+ project = resolve_project(path)
297
371
  except ContinuousRefactorError as error:
298
372
  print(f"Error: {error}", file=sys.stderr)
299
373
  raise SystemExit(1) from error
@@ -302,11 +376,110 @@ def _handle_init(args: argparse.Namespace) -> None:
302
376
  print(f"Data directory: {project.project_dir}")
303
377
  print(f"Taste file: {taste_path}")
304
378
  if live_dir_arg is not None:
379
+ assert resolved_live is not None
305
380
  print(f"Live migrations dir: {resolved_live}")
306
381
 
307
382
 
383
+ def _configure_repo_taste(
384
+ *,
385
+ current: Path,
386
+ destination: Path,
387
+ force: bool,
388
+ ensure_taste_file: Callable[[Path], Path],
389
+ ) -> None:
390
+ if not current.exists():
391
+ ensure_taste_file(destination)
392
+ return
393
+ if current.resolve() == destination.resolve():
394
+ return
395
+ if not current.is_file():
396
+ raise ContinuousRefactorError(
397
+ f"Configured taste path is not a file: {current}"
398
+ )
399
+ if destination.exists() and not force:
400
+ raise ContinuousRefactorError(
401
+ "Taste destination already exists: "
402
+ f"{destination}. Re-run init with --force to replace it."
403
+ )
404
+
405
+ destination.parent.mkdir(parents=True, exist_ok=True)
406
+ try:
407
+ shutil.move(str(current), str(destination))
408
+ except OSError as exc:
409
+ raise ContinuousRefactorError(
410
+ f"Could not move taste file from {current} to {destination}."
411
+ ) from exc
412
+
413
+
414
+ def _configure_live_migrations_dir(
415
+ *,
416
+ current: Path | None,
417
+ destination: Path,
418
+ force: bool,
419
+ ) -> None:
420
+ if current is None or not current.exists():
421
+ destination.mkdir(parents=True, exist_ok=True)
422
+ return
423
+ if not current.is_dir():
424
+ raise ContinuousRefactorError(
425
+ f"Configured live migrations path is not a directory: {current}"
426
+ )
427
+ if current.resolve() == destination.resolve():
428
+ return
429
+ if (
430
+ destination.resolve().is_relative_to(current.resolve())
431
+ or current.resolve().is_relative_to(destination.resolve())
432
+ ):
433
+ raise ContinuousRefactorError(
434
+ "Live migrations directory cannot be moved into itself or one of "
435
+ f"its parents: {current} -> {destination}"
436
+ )
437
+
438
+ backup_destination: Path | None = None
439
+ removed_empty_destination = False
440
+ if destination.exists():
441
+ if not destination.is_dir():
442
+ raise ContinuousRefactorError(
443
+ f"Live migrations destination is not a directory: {destination}"
444
+ )
445
+ if any(destination.iterdir()) and not force:
446
+ raise ContinuousRefactorError(
447
+ "Live migrations destination already exists and is not empty: "
448
+ f"{destination}. Re-run init with --force to replace it."
449
+ )
450
+ if force:
451
+ backup_name = (
452
+ f".{destination.name}."
453
+ f"continuous-refactoring-replaced-{uuid.uuid4().hex}"
454
+ )
455
+ backup_destination = destination.with_name(backup_name)
456
+ destination.rename(backup_destination)
457
+ else:
458
+ destination.rmdir()
459
+ removed_empty_destination = True
460
+
461
+ destination.parent.mkdir(parents=True, exist_ok=True)
462
+ try:
463
+ shutil.move(str(current), str(destination))
464
+ except OSError as exc:
465
+ if backup_destination is not None and not destination.exists():
466
+ backup_destination.rename(destination)
467
+ elif removed_empty_destination:
468
+ destination.mkdir(parents=True, exist_ok=True)
469
+ raise ContinuousRefactorError(
470
+ "Could not move live migrations directory from "
471
+ f"{current} to {destination}."
472
+ ) from exc
473
+ if backup_destination is not None:
474
+ shutil.rmtree(backup_destination, ignore_errors=True)
475
+
476
+
308
477
  def _resolve_taste_path(global_: bool) -> Path:
309
- from continuous_refactoring.config import global_dir, resolve_project
478
+ from continuous_refactoring.config import (
479
+ global_dir,
480
+ resolve_project,
481
+ resolve_project_taste_path,
482
+ )
310
483
 
311
484
  if global_:
312
485
  path = global_dir() / "taste.md"
@@ -323,7 +496,7 @@ def _resolve_taste_path(global_: bool) -> Path:
323
496
  file=sys.stderr,
324
497
  )
325
498
  raise SystemExit(1) from error
326
- return project.project_dir / "taste.md"
499
+ return resolve_project_taste_path(project)
327
500
 
328
501
 
329
502
  def _taste_settle_path(path: Path) -> Path:
@@ -14,6 +14,7 @@ from continuous_refactoring.artifacts import ContinuousRefactorError
14
14
 
15
15
  __all__ = [
16
16
  "CONFIG_CURRENT_VERSION",
17
+ "DEFAULT_REPO_TASTE_PATH",
17
18
  "ProjectEntry",
18
19
  "ResolvedProject",
19
20
  "TASTE_CURRENT_VERSION",
@@ -31,15 +32,18 @@ __all__ = [
31
32
  "parse_taste_version",
32
33
  "register_project",
33
34
  "resolve_live_migrations_dir",
35
+ "resolve_project_taste_path",
34
36
  "resolve_project",
35
37
  "save_manifest",
36
38
  "set_live_migrations_dir",
39
+ "set_repo_taste_path",
37
40
  "taste_is_stale",
38
41
  "xdg_data_home",
39
42
  ]
40
43
 
41
44
  CONFIG_CURRENT_VERSION = 1
42
45
  TASTE_CURRENT_VERSION = 1
46
+ DEFAULT_REPO_TASTE_PATH = ".continuous-refactoring/taste.md"
43
47
 
44
48
  _DEFAULT_TASTE = """\
45
49
  taste-scoping-version: 1
@@ -69,6 +73,7 @@ class ProjectEntry:
69
73
  git_remote: str | None
70
74
  created_at: str
71
75
  live_migrations_dir: str | None = None
76
+ repo_taste_path: str | None = None
72
77
 
73
78
 
74
79
  @dataclass(frozen=True)
@@ -182,6 +187,12 @@ def _entry_from_object(uid: str, data: object) -> ProjectEntry:
182
187
  project_id=uid,
183
188
  required=False,
184
189
  ),
190
+ repo_taste_path=_string_field(
191
+ data,
192
+ "repo_taste_path",
193
+ project_id=uid,
194
+ required=False,
195
+ ),
185
196
  )
186
197
 
187
198
 
@@ -310,6 +321,18 @@ def resolve_live_migrations_dir(project: ResolvedProject) -> Path | None:
310
321
  return resolved
311
322
 
312
323
 
324
+ def resolve_project_taste_path(project: ResolvedProject) -> Path:
325
+ if project.entry.repo_taste_path is None:
326
+ return project.project_dir / "taste.md"
327
+ repo_root = Path(project.entry.path).resolve()
328
+ resolved = (repo_root / project.entry.repo_taste_path).resolve()
329
+ if not resolved.is_relative_to(repo_root):
330
+ raise ContinuousRefactorError(
331
+ f"repo_taste_path escapes repo: {project.entry.repo_taste_path}"
332
+ )
333
+ return resolved
334
+
335
+
313
336
  def _get_project(manifest: dict[str, ProjectEntry], project_uuid: str) -> ProjectEntry:
314
337
  project = manifest.get(project_uuid)
315
338
  if project is None:
@@ -324,6 +347,13 @@ def set_live_migrations_dir(project_uuid: str, relative_dir: str) -> None:
324
347
  save_manifest(manifest)
325
348
 
326
349
 
350
+ def set_repo_taste_path(project_uuid: str, relative_path: str) -> None:
351
+ manifest = load_manifest()
352
+ old = _get_project(manifest, project_uuid)
353
+ manifest[project_uuid] = replace(old, repo_taste_path=relative_path)
354
+ save_manifest(manifest)
355
+
356
+
327
357
  # ---------------------------------------------------------------------------
328
358
  # Taste
329
359
  # ---------------------------------------------------------------------------
@@ -349,6 +379,8 @@ def default_taste_text() -> str:
349
379
 
350
380
 
351
381
  def ensure_taste_file(path: Path) -> Path:
382
+ if path.exists() and not path.is_file():
383
+ raise ContinuousRefactorError(f"Taste path is not a file: {path}")
352
384
  if not path.exists():
353
385
  path.parent.mkdir(parents=True, exist_ok=True)
354
386
  path.write_text(_DEFAULT_TASTE, encoding="utf-8")
@@ -366,7 +398,7 @@ def _read_taste_text(path: Path) -> str:
366
398
 
367
399
  def load_taste(project: ResolvedProject | None) -> str:
368
400
  if project is not None:
369
- project_taste = project.project_dir / "taste.md"
401
+ project_taste = resolve_project_taste_path(project)
370
402
  if project_taste.exists():
371
403
  return _read_taste_text(project_taste)
372
404