Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions docs/development/semantic-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,24 @@ appends) record the complete desired text and also retain individual region
texts. If overlapping edits prevent the whole-file merge, clean region updates
can still apply independently.
A pre-existing unowned generated target can own a newly appended region without
acquiring whole-file ownership. When a previously managed region is omitted, merge
mode skips whole-file regeneration for that run, because regenerating without the
region would drop it without its decision; the region step retracts it (and from
the whole-file baseline too), and the next run regenerates.
acquiring whole-file ownership. When a previously managed region is omitted, the
generated writer retracts it before regenerating, so the file converges in the
same run: an unedited region is removed from the file and cut from the whole-file
baseline, one the user already deleted is forgotten, and a settled one is taken
or kept. A region kept with its local edit is the user's, so it sits out the
three-way merge (a generated line changed next to it would otherwise overlap) and
is appended after the merged text. While an edited omitted region waits for its
decision, the writer leaves the file alone and the region step reports it; the
file regenerates in the run that settles it. A target where Protostar owns only
regions (`regions` policy) is left to the region step.

A generated file whose tool is switched off (`EnvironmentManifest.generated_files`
no longer lists it) and that receives no declared region is released the way a
seed is, by `Reconciliation._release_undeclared_generated`: deleted when its text
matches the whole-file baseline, forgotten when already deleted, and otherwise a
`retracted` conflict for the whole file, whose `local` choice keeps it as the
user's and `desired` deletes it. Generated text has no units to keep apart, so
the decision is never split into hunks.

## PR G resolver and derived-artifact boundary

Expand Down
4 changes: 3 additions & 1 deletion docs/usage/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ A whole configuration file whose tool you turn off, such as `zensical.toml`,
`.github/codecov.yml`, `.readthedocs.yaml`, a workflow, the hook configuration,
Renovate's, or the VS Code settings, is retracted key by key the same way. When
nothing of Protostar's is left, the file is deleted if it holds nothing else and
kept with only your own keys if it does.
kept with only your own keys if it does. A generated `justfile` or `Dockerfile`
whose tool you turn off is deleted when unedited; an edited one is kept with a
`retracted` conflict for the whole file.
Seeded project metadata such as `[project].name` and dependency-group includes
are never retracted. Workflow files are
merged by job and by step name, so your own jobs, steps, triggers, and inputs stay.
Expand Down
92 changes: 87 additions & 5 deletions src/protostar/appends.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Generic marker-block file append engine."""

import re
from collections.abc import Iterable
from dataclasses import dataclass, replace
from pathlib import Path

Expand All @@ -22,6 +23,9 @@
__all__ = [
"RegionResult",
"append_marker_blocks",
"attach_regions",
"cut_regions",
"detach_regions",
"get_comment_markers",
]

Expand Down Expand Up @@ -105,6 +109,79 @@ class RegionResult:
preserved: tuple[MergeConflict, ...] = ()


def _marker(filepath: Path, tag: str, end: bool = False) -> str:
"""Returns a region's begin or end marker in the file's comment syntax."""
c_start, c_end = get_comment_markers(filepath)
prefix = "endregion" if end else "region"
suffix = f" {c_end}" if c_end else ""
return f"{c_start} {prefix}: protostar {tag}{suffix}".strip()


def cut_regions(text: str, identities: Iterable[str], filepath: Path) -> str:
"""Removes regions from a text whatever they hold, such as a recorded baseline.

Args:
text: Text whose regions are well formed.
identities: The regions to remove; absent ones are skipped.
filepath: The file, which selects the comment syntax of the markers.

Returns:
The text without those regions, spaced as appending left it.
"""
for identity in identities:
tag = region_tag(identity)
begin, end = _marker(filepath, tag), _marker(filepath, tag, True)
if begin in text:
start = text.index(begin)
text = _cut(text, start, text.index(end, start) + len(end))
return text


def detach_regions(
text: str, identities: Iterable[str], filepath: Path
) -> tuple[str, tuple[str, ...]]:
"""Takes regions out of a text, to put back with ``attach_regions``.

Args:
text: Text whose regions are well formed.
identities: The regions to take out; absent ones are skipped.
filepath: The file, which selects the comment syntax of the markers.

Returns:
The text without those regions, and each region's framed text in order.
"""
blocks: list[tuple[int, str]] = []
for identity in identities:
tag = region_tag(identity)
begin, end = _marker(filepath, tag), _marker(filepath, tag, True)
if begin in text:
start = text.index(begin)
blocks.append((start, text[start : text.index(end, start) + len(end)]))
detached = cut_regions(text, identities, filepath)
return detached, tuple(block for _, block in sorted(blocks))


def attach_regions(text: str, blocks: Iterable[str]) -> str:
"""Appends framed regions to a text the way a new region is appended.

Args:
text: The text to extend.
blocks: Framed region texts, in order.

Returns:
The text with each region appended after a blank line.
"""
for block in blocks:
newline = "\r\n" if "\r\n" in text or "\r\n" in block else "\n"
separator = (
""
if not text
else (newline if text.endswith(("\r\n", "\n")) else newline + newline)
)
text += separator + block + newline
return text


def _cut(text: str, start: int, stop: int) -> str:
"""Removes a region and the line break after it, and the blank line it needed."""
if text[stop : stop + 2] == "\r\n":
Expand All @@ -113,8 +190,16 @@ def _cut(text: str, start: int, stop: int) -> str:
stop += 1
head, tail = text[:start], text[stop:]
# Appending put one blank line before the region; one is enough.
if head.endswith("\n\n") and (not tail or tail.startswith("\n")):
if head.endswith(("\r\n\r\n", "\n\r\n")) and (
not tail or tail.startswith(("\r\n", "\n"))
):
head = head[:-2]
elif head.endswith(("\r\n\n", "\n\n")) and (
not tail or tail.startswith(("\r\n", "\n"))
):
head = head[:-1]
elif not head and tail.startswith("\r\n"):
tail = tail[2:]
elif not head and tail.startswith("\n"):
tail = tail[1:]
return head + tail
Expand Down Expand Up @@ -149,12 +234,9 @@ def append_marker_blocks(
Returns:
The reconciled content, applied region texts, and refused regions.
"""
c_start, c_end = get_comment_markers(filepath)

def marker(tag: str, end: bool = False) -> str:
prefix = "endregion" if end else "region"
suffix = f" {c_end}" if c_end else ""
return f"{c_start} {prefix}: protostar {tag}{suffix}".strip()
return _marker(filepath, tag, end)

active: str | None = None
seen: set[str] = set()
Expand Down
18 changes: 14 additions & 4 deletions src/protostar/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,19 @@ def declared_documents(self) -> set[str]:
path for target in targets for path in self.document_locations(target).paths
}

def generated_files(self) -> set[str]:
"""Returns the text files a tool generates whole in this run.

Returns:
Workspace-relative POSIX paths.
"""
files: set[str] = set()
if self.tooling.wants_just:
files.add("justfile")
if self.tooling.wants_docker:
files.add(DOCKERFILE)
return files

def target_files(self) -> set[Path]:
"""Returns all concrete workspace file paths that this manifest intends to create or mutate.

Expand Down Expand Up @@ -721,11 +734,8 @@ def target_files(self) -> set[Path]:
if self.tooling.wants_release:
targets.add(Path(github_workflows.RELEASE_TARGET))

if self.tooling.wants_just:
targets.add(Path("justfile"))

targets.update(Path(path) for path in self.generated_files())
if self.tooling.wants_docker:
targets.add(Path(DOCKERFILE))
targets.add(Path(".dockerignore"))

return targets
Expand Down
1 change: 1 addition & 0 deletions src/protostar/preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ def prepare_review(
decisions._release_undeclared_seeds()
decisions._settle_retired()
decisions._release_undeclared_documents()
decisions._release_undeclared_generated()
decisions._create_directories()
decisions._write_injected_files()
decisions._write_pre_commit_config()
Expand Down
Loading
Loading