Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0537412
style: apply ruff format to source and test files
Coding-Dev-Tools Aug 10, 2026
c879727
fix(ci): SHA-pin checkout in auto-pr workflow + add empty-term guard …
Coding-Dev-Tools Aug 15, 2026
2ed1ff5
feat(cli): add command to apply baseline values to drifted target co…
Coding-Dev-Tools Aug 16, 2026
f25e946
cowork-bot: atomic file writes for fix command (prevent config corrup…
Coding-Dev-Tools Aug 16, 2026
74fcdf6
fix: address 6 review issues in cli.py and _atomic.py
Coding-Dev-Tools Aug 18, 2026
a4925f2
fix: preserve collection values, literal dotted keys, failure exit, .…
Coding-Dev-Tools Aug 18, 2026
7e86bd6
fix: resolve symlinks before atomic write, validate dry-run format su…
Coding-Dev-Tools Aug 18, 2026
454b25e
fix: recognize .env filename, preserve null values and empty mappings
Coding-Dev-Tools Aug 18, 2026
624863f
fix: distinguish null from missing keys, recognize .env.* dotenv vari…
Coding-Dev-Tools Aug 18, 2026
4fa0fb3
fix: emit dotenv booleans as lowercase, handle date/datetime in JSON …
Coding-Dev-Tools Aug 18, 2026
b6e7f40
fix: handle escaped quotes in dotenv comments, reject null in TOML wr…
Coding-Dev-Tools Aug 18, 2026
d9ee511
fix: include .env extension in dotenv detection, validate TOML nulls …
Coding-Dev-Tools Aug 18, 2026
9cec714
fix: preserve owner during atomic replace, normalize dotenv booleans,…
Coding-Dev-Tools Aug 18, 2026
99d38ca
fix: normalize all dotenv scalars before comparison, handle non-strin…
Coding-Dev-Tools Aug 18, 2026
43a9b57
fix: normalize dotenv nulls, validate keys and multiline in dry runs,…
Coding-Dev-Tools Aug 18, 2026
0a6d925
fix: abort on chown failure, recursive TOML null check, reject \r in …
Coding-Dev-Tools Aug 18, 2026
792df27
fix: guard os.chown for Windows, reject non-string JSON keys
Coding-Dev-Tools Aug 18, 2026
2a58aaf
fix: preserve literal dotted keys, reject dotenv collections, quote tabs
Coding-Dev-Tools Aug 18, 2026
5de81a3
fix: revert load_file to dict return, cache literal-dotted keys per path
Coding-Dev-Tools Aug 18, 2026
aff07db
fix: address 5 Codex review threads (v15)
Coding-Dev-Tools Aug 18, 2026
6f6ff17
fix: depth-aware nested literal key reconstruction + dry-run collecti…
Coding-Dev-Tools Aug 18, 2026
18ec9d3
chore: fix ruff lint errors (imports + SIM108)
Coding-Dev-Tools Aug 18, 2026
ff30bc0
fix: tuple-path literal dotted tracking + TypeError guard on JSON wri…
Coding-Dev-Tools Aug 18, 2026
a1a0596
fix: address 7 Codex v18 review threads
Coding-Dev-Tools Aug 18, 2026
f9c987b
fix(loader,cli): address Codex v19 review threads (collision guard + …
Coding-Dev-Tools Aug 19, 2026
218b8be
style: fix ruff format on _atomic.py per automated code review
Coding-Dev-Tools Aug 19, 2026
a6dd9cd
chore: remove diagnostic artifact and harden .gitignore
Coding-Dev-Tools Aug 19, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/cowork-auto-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
# without this step every run failed with "not a git repository" and no
# PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it).
- name: Check out the pushed branch
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,8 @@ nul

# npm lock artifact (Python-only project)
package-lock.json

# Diagnostic artifacts from code review tooling
_gql_threads.txt
threads_dump.json
*_dump.json
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"rich>=13.0.0",
"pyyaml>=6.0",
"tomli>=2.0.0; python_version < '3.11'",
"tomli-w>=1.0.0",
]

[project.optional-dependencies]
Expand Down
124 changes: 124 additions & 0 deletions src/configdrift/_atomic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Atomic file-write helpers.

Write to a temporary file in the same directory, fsync, then os.replace()
to atomically swap. If the process crashes mid-write the original file
is preserved intact.
"""

from __future__ import annotations

import contextlib
import os
import tempfile
from pathlib import Path


def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
"""Atomically write *text* to *path*.

Creates a temporary file beside *path*, writes + fsyncs, then
``os.replace()`` for an atomic rename. Preserves the original
file's permissions when it already exists. When *path* is a
symlink, resolves it first so the referent is updated rather than
the link being replaced by a regular file.
"""
# Resolve symlinks so we update the target file, not replace the link.
resolved = path.resolve() if path.is_symlink() else path
parent = resolved.parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=parent, suffix=".tmp")
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
try:
with os.fdopen(fd, "w", encoding=encoding, newline="") as fh:
fh.write(text)
fh.flush()
os.fsync(fh.fileno())
# Preserve target permissions and ownership during atomic replacement.
# If ownership restoration fails (e.g. unprivileged caller), abort
# the replacement rather than installing a caller-owned file that
# the application cannot read.
if resolved.exists():
st = resolved.stat()
os.chmod(tmp, st.st_mode)
try:
if hasattr(os, "chown"):
os.chown(tmp, st.st_uid, st.st_gid)
else:
# os.chown is Unix-only; on Windows ownership is managed
# by the filesystem ACLs and mkstemp already creates
# the temp file with the caller's identity.
pass
except OSError as chown_err:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise OSError(
f"Cannot preserve ownership of {resolved} (uid={st.st_uid}, gid={st.st_gid}): {chown_err}"
) from chown_err
os.replace(tmp, resolved)
except BaseException:
# Clean up temp file on any failure
with contextlib.suppress(OSError):
os.unlink(tmp)
raise


def atomic_write_bytes(path: Path, data: bytes) -> None:
"""Atomically write *data* to *path*.

Preserves the original file's permissions when it already exists.
When *path* is a symlink, resolves it first so the referent is
updated rather than the link being replaced by a regular file.
"""
resolved = path.resolve() if path.is_symlink() else path
parent = resolved.parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=parent, suffix=".tmp")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
# Preserve target permissions and ownership during atomic replacement.
# If ownership restoration fails, abort rather than installing a
# caller-owned file the application cannot read.
if resolved.exists():
st = resolved.stat()
os.chmod(tmp, st.st_mode)
try:
if hasattr(os, "chown"):
os.chown(tmp, st.st_uid, st.st_gid)
else:
# os.chown is Unix-only; on Windows ownership is managed
# by the filesystem ACLs and mkstemp already creates
# the temp file with the caller's identity.
pass
except OSError as chown_err:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise OSError(
f"Cannot preserve ownership of {resolved} (uid={st.st_uid}, gid={st.st_gid}): {chown_err}"
) from chown_err
os.replace(tmp, resolved)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise


def atomic_dump_yaml(path: Path, data: object, **dump_kwargs: object) -> None:
"""Serialize *data* via ``yaml.dump`` into a temp file, then atomically rename."""
import io
import yaml

buf = io.StringIO()
yaml.dump(data, buf, **dump_kwargs) # type: ignore[arg-type]
atomic_write_text(path, buf.getvalue())


def atomic_dump_toml(path: Path, data: object) -> None:
"""Serialize *data* via ``tomli_w.dump`` into a temp file, then atomically rename."""
import io
import tomli_w

buf = io.BytesIO()
tomli_w.dump(data, buf) # type: ignore[arg-type]
atomic_write_bytes(path, buf.getvalue())
Loading
Loading