add dcsm gitignore command + dev tooling overhaul#2
Conversation
Adds a new `dcsm gitignore` subcommand that walks the DCSM_TEMPLATE_* directories and, for each `.template` file, finds the closest ancestor .gitignore (bounded by the template root) and writes the resulting filename into a managed `# BEGIN DCSM ... # END DCSM` block. If no ancestor .gitignore exists, a single one is created at the template root so a fresh tree collects all entries into one file. The command is idempotent: it only rewrites a file when the contents would actually change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a new dcsm gitignore subcommand to automatically maintain .gitignore entries for decrypted outputs of *.template files, reducing git status noise after running dcsm.
Changes:
- Implement
dcsm gitignoreto discover templates underDCSM_TEMPLATE_*directories, choose the nearest in-tree.gitignore, and update a managed ignore block. - Add unit + end-to-end tests covering proximity selection, block updates (create/append/replace/idempotent), and relative path rendering.
- Document the new workflow in the README.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
dcsm.py |
Adds template discovery + .gitignore proximity selection and managed-block update logic; wires new gitignore subcommand into main(). |
test.py |
Adds comprehensive tests for find_proximate_gitignore, update_gitignore, and the end-to-end gitignore command behavior. |
README.md |
Documents the new dcsm gitignore command and the managed .gitignore block semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| original = path.read_text() if path.exists() else '' | ||
|
|
There was a problem hiding this comment.
update_gitignore uses path.read_text() without an explicit encoding. For deterministic behavior across locales (and to avoid surprises in non-UTF-8 environments), consider passing encoding='utf-8' explicitly.
| path.write_text(replaced) | ||
| return True |
There was a problem hiding this comment.
update_gitignore uses path.write_text(replaced) without an explicit encoding. Consider using encoding='utf-8' to keep the file content stable across different locale/default-encoding configurations.
| assert template.is_relative_to(template_root), ( | ||
| f'{template} is not under template root {template_root}' | ||
| ) |
There was a problem hiding this comment.
find_proximate_gitignore uses an assert template.is_relative_to(template_root) for input validation. Asserts can be disabled with Python optimizations (-O), which would skip this safety check and potentially let the function walk outside the intended root. Prefer an explicit runtime check that raises a ValueError (or similar) when template is not under template_root.
| assert template.is_relative_to(template_root), ( | |
| f'{template} is not under template root {template_root}' | |
| ) | |
| if not template.is_relative_to(template_root): | |
| raise ValueError( | |
| f'{template} is not under template root {template_root}' | |
| ) |
| m = pattern.search(original) | ||
| if m: | ||
| before = original[:m.start()].rstrip('\n') | ||
| after = original[m.end():].lstrip('\n') |
There was a problem hiding this comment.
update_gitignore only searches for and replaces the first managed block (pattern.search). If a .gitignore already contains multiple DCSM-managed blocks (e.g., from a merge conflict or previous bug), this will leave extra blocks behind and can produce confusing results. Consider detecting multiple blocks and either replacing all of them or raising an error that instructs the user to fix the file.
| m = pattern.search(original) | |
| if m: | |
| before = original[:m.start()].rstrip('\n') | |
| after = original[m.end():].lstrip('\n') | |
| matches = list(pattern.finditer(original)) | |
| if matches: | |
| before = original[:matches[0].start()].rstrip('\n') | |
| after = original[matches[-1].end():].lstrip('\n') |
| r'\n*' + re.escape(GITIGNORE_BEGIN) + r'\n.*?' + re.escape(GITIGNORE_END) + r'\n*', | ||
| re.DOTALL, | ||
| ) | ||
| m = pattern.search(original) | ||
| if m: | ||
| before = original[:m.start()].rstrip('\n') | ||
| after = original[m.end():].lstrip('\n') | ||
| else: | ||
| before = original.rstrip('\n') | ||
| after = '' | ||
|
|
||
| parts: list[str] = [] | ||
| if before: | ||
| parts.append(before + '\n\n') | ||
| parts.append(new_block) | ||
| if after: | ||
| parts.append('\n' + after) | ||
| replaced = ''.join(parts) | ||
|
|
There was a problem hiding this comment.
update_gitignore trims surrounding newlines (rstrip('\n') / lstrip('\n')) and then re-inserts normalized spacing ('\n\n' before the block, '\n' before after). This can rewrite parts of the file outside the managed block (e.g., collapsing/expanding blank lines) even when the managed entries themselves are unchanged. If the goal is to only update the managed block, preserve the exact original prefix/suffix whitespace around the match, or document that the command canonicalizes formatting.
| r'\n*' + re.escape(GITIGNORE_BEGIN) + r'\n.*?' + re.escape(GITIGNORE_END) + r'\n*', | |
| re.DOTALL, | |
| ) | |
| m = pattern.search(original) | |
| if m: | |
| before = original[:m.start()].rstrip('\n') | |
| after = original[m.end():].lstrip('\n') | |
| else: | |
| before = original.rstrip('\n') | |
| after = '' | |
| parts: list[str] = [] | |
| if before: | |
| parts.append(before + '\n\n') | |
| parts.append(new_block) | |
| if after: | |
| parts.append('\n' + after) | |
| replaced = ''.join(parts) | |
| r'(?P<leading>\n*)' | |
| + re.escape(GITIGNORE_BEGIN) | |
| + r'\n.*?' | |
| + re.escape(GITIGNORE_END) | |
| + r'(?P<trailing>\n*)', | |
| re.DOTALL, | |
| ) | |
| m = pattern.search(original) | |
| if m: | |
| replaced = ( | |
| original[:m.start()] | |
| + m.group('leading') | |
| + new_block | |
| + m.group('trailing') | |
| + original[m.end():] | |
| ) | |
| else: | |
| trailing_newlines = len(original) - len(original.rstrip('\n')) | |
| if not original: | |
| separator = '' | |
| elif trailing_newlines == 0: | |
| separator = '\n\n' | |
| elif trailing_newlines == 1: | |
| separator = '\n' | |
| else: | |
| separator = '' | |
| replaced = original + separator + new_block |
| try: | ||
| rel = output.relative_to(ignore_path.parent) | ||
| except ValueError: | ||
| # output is not under the gitignore's directory -- fall back | ||
| # to an absolute-from-gitignore-dir style by skipping | ||
| rel = Path(output.name) |
There was a problem hiding this comment.
In gitignore(), the try/except ValueError around output.relative_to(ignore_path.parent) should be unreachable given the current find_proximate_gitignore behavior (the chosen .gitignore is always an ancestor of the template/output). The fallback also only uses output.name, which is not an "absolute-from-gitignore-dir" style path as the comment suggests. Consider removing the try/except (or replacing it with an explicit invariant check) to keep the control flow and comment accurate.
| try: | |
| rel = output.relative_to(ignore_path.parent) | |
| except ValueError: | |
| # output is not under the gitignore's directory -- fall back | |
| # to an absolute-from-gitignore-dir style by skipping | |
| rel = Path(output.name) | |
| if not output.is_relative_to(ignore_path.parent): | |
| raise ValueError( | |
| f'output path {output} is not under gitignore directory ' | |
| f'{ignore_path.parent}' | |
| ) | |
| rel = output.relative_to(ignore_path.parent) |
- Drop `.python-version` in favor of `mise.toml`, which pins python and auto-creates a `.venv` on entering the directory. - Promote `pyproject.toml` to define the project (runtime dep: PyYAML; dev deps: mypy + types-PyYAML) so dependencies live in one place. - Add `just setup` recipe which installs the project in editable mode with dev extras into the active venv via `uv pip install`. - Ignore `.venv` and `dcsm.egg-info` produced by the local install. Skipping mise tasks for now since the justfile already serves that role. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add `ruff` to dev dependencies in pyproject.toml. - Configure isort (`I`) and flake8-bugbear (`B`) lint rules on top of ruff's defaults. - Add `just format` recipe to autoformat sources and apply lint fixes. - Wire `ruff check` and `ruff format --check` into `just test` so the test recipe gates on style as well as types and behavior. This commit only adds the tooling; the existing source does not yet satisfy the new checks. The follow-up commit fixes those issues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reformat both source files via `ruff format` (largely a quote-style swap from single to double). - Sort imports per isort. - Rename unused `os.walk` directory captures to `_dirs` (B007). - Drop unused tuple unpacking in `get_secrets` -- iterate directly over the dict (B007). - Chain the template-substitution exception with `from e` so the original cause is preserved in the traceback (B904). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the brief 'Dev' / 'Requirements' sections with a 'Developing' section that covers: - the mise + just toolchain and how the project-local venv is created - `just setup` for installing runtime + dev deps from pyproject.toml - what `just test` actually runs (ruff, mypy, unit tests, e2e compose) - a note that there is no CI test gate today; only Docker publish runs in GitHub Actions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gitignore command to manage .gitignore for decrypted filesdcsm gitignore command + dev tooling overhaul
The Compose Specification dropped the top-level `version` key; modern `docker compose` warns about it on every invocation. Remove it from `docker-compose.yml` and from the example block in the README. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
eb85931 to
5527c0d
Compare
- Pass `encoding="utf-8"` to `read_text`/`write_text` in `update_gitignore` so behavior is deterministic across locales. - Replace the `assert template.is_relative_to(template_root)` in `find_proximate_gitignore` with an explicit `ValueError`. Asserts are stripped under `python -O`, which would silently let the function walk outside the intended root. - Drop the unreachable `try/except ValueError` around `output.relative_to(ignore_path.parent)` in `gitignore()`. The invariant from `find_proximate_gitignore` (the chosen `.gitignore` is always at or above the template's directory) makes the call total. Replaced with a comment explaining the invariant. Add a test that `find_proximate_gitignore` raises `ValueError` when the template lives outside the template root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a `.gitignore` ends up with more than one DCSM-managed block (e.g. left behind by a merge conflict or earlier bug), `update_gitignore` previously only rewrote the first block and left the others in place. Switch from `pattern.search` to `pattern.finditer` and span from the first match's start to the last match's end, so all stale blocks collapse into one freshly-rendered block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
This PR started as a single feature (a
dcsm gitignoresubcommand to stop the parade of untracked decrypted files ingit status) and grew into a small dev-workflow refresh along the way. Each concern is in its own commit so reviewing one at a time is easy.Feature:
dcsm gitignoreFor each
*.templatefile under anyDCSM_TEMPLATE_*directory, finds the closest ancestor.gitignore(bounded by the template root) and writes the resulting decrypted filename into a managed block:.gitignoreends up managing them, so an intermediate.gitignoregets bare filenames while the root.gitignoregets full subpaths..gitignoreexists anywhere within the template root, a single one is created at the template root rather than scattering one per template directory.Dev environment
.python-version; manage Python viamise.toml(auto-creates a project-local.venv).pyproject.tomlto declare the project, withPyYAMLas the runtime dep andmypy,ruff,types-PyYAMLas dev extras.just setup(installs.[dev]into the active venv viauv pip install).rufffor lint + format (isort + bugbear on top of defaults), wired intojust testand exposed via ajust formatrecipe.os.walkcaptures, B007 unused dict-items unpacking inget_secrets, B904 missing exception chaining intemplate_file).just setup,just test,just format, and noting that CI does not currently runjust test— onlypublish.yamlfor Docker image build/push.versionkey fromdocker-compose.ymland from the example block in the README (moderndocker composewarns about it on every invocation).Test plan
just test— locally passesruff check,ruff format --check,mypy --strict, 18 unit tests, and the e2edocker compose up.dcsm gitignoreend-to-end behavior covered by 5 unit tests:.gitignoreat template root with all entries