Skip to content

Commit 07c34d0

Browse files
committed
fix: address PR review comments and CI formatting issues
- capture.py: Use record/field separators (0x1e/0x1f) for git log parsing to handle multi-line commit bodies safely; fix since/until handling to distinguish date expressions from ref ranges; add explanatory comment on bare except clause - executor.py: Check returncode in _git_stash_push before inspecting stdout; treat build failure as unsuccessful regardless of stash state; set success=False when any actions failed - planner.py: Set field_changes={'status':'review'} in detect_conflicts instead of empty dict; raise ValueError for unknown check names - mcp_server.py: Fix pipx install syntax from 'ai_memory_protocol/[mcp]' to '.[mcp]' - cli.py: Remove unnecessary lambda wrappers around no-arg callables - ci.yml: Add --cov-report=html for htmlcov/ artifact upload; add pytest-timeout to integration test dependencies - Run ruff format on all files to fix formatting CI failure
1 parent 1cc4076 commit 07c34d0

10 files changed

Lines changed: 461 additions & 423 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ jobs:
6464
python -c "from ai_memory_protocol.mcp_server import create_mcp_server; create_mcp_server()"
6565
6666
- name: Run unit tests
67-
run: pytest tests/ -v -m "not integration" --cov=ai_memory_protocol --cov-report=term-missing
67+
run: pytest tests/ -v -m "not integration" --cov=ai_memory_protocol --cov-report=term-missing --cov-report=html
6868

6969
- name: Upload coverage
7070
if: matrix.python-version == '3.12'
@@ -85,7 +85,7 @@ jobs:
8585
python-version: "3.12"
8686

8787
- name: Install package
88-
run: pip install -e '.[mcp]' pytest
88+
run: pip install -e '.[mcp]' pytest pytest-timeout
8989

9090
- name: Run integration tests
9191
run: pytest tests/ -v -m integration --timeout=120

src/ai_memory_protocol/capture.py

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ def to_dict(self) -> dict[str, Any]:
5656
# ---------------------------------------------------------------------------
5757

5858

59-
_GIT_LOG_SEP = "---MEMORY_SEP---"
60-
_GIT_LOG_FORMAT = f"%H{_GIT_LOG_SEP}%s{_GIT_LOG_SEP}%b{_GIT_LOG_SEP}%an{_GIT_LOG_SEP}%ad"
59+
_GIT_RECORD_SEP = "\x1e" # ASCII Record Separator between commits
60+
_GIT_FIELD_SEP = "\x1f" # ASCII Unit Separator between fields
61+
_GIT_LOG_FORMAT = (
62+
f"%H{_GIT_FIELD_SEP}%s{_GIT_FIELD_SEP}%b{_GIT_FIELD_SEP}%an{_GIT_FIELD_SEP}%ad{_GIT_RECORD_SEP}"
63+
)
6164

6265

6366
@dataclass
@@ -74,35 +77,40 @@ class _GitCommit:
7477

7578
def _parse_git_log(repo_path: Path, since: str, until: str) -> list[_GitCommit]:
7679
"""Run git log and parse the output."""
77-
cmd = [
80+
# Base git log command
81+
cmd: list[str] = [
7882
"git",
7983
"log",
8084
f"--format={_GIT_LOG_FORMAT}",
8185
"--date=iso-strict",
82-
f"{since}..{until}" if ".." not in since and until != "HEAD" else f"{since}...{until}",
8386
]
8487

85-
# Use simpler range format
86-
if since and until:
87-
cmd = [
88-
"git",
89-
"log",
90-
f"--format={_GIT_LOG_FORMAT}",
91-
"--date=iso-strict",
92-
f"--since={since}" if not since.startswith("HEAD") else "",
93-
f"--until={until}" if not until.startswith("HEAD") else "",
94-
]
95-
cmd = [c for c in cmd if c] # Remove empty strings
96-
97-
# Fallback: use commit range directly
98-
if since.startswith("HEAD"):
99-
cmd = [
100-
"git",
101-
"log",
102-
f"--format={_GIT_LOG_FORMAT}",
103-
"--date=iso-strict",
104-
f"{since}",
105-
]
88+
# Heuristic: if arguments contain spaces (e.g. "2 weeks ago"), treat them as
89+
# date expressions and use --since/--until. Otherwise treat them as refs and
90+
# use git's revision range syntax.
91+
has_since = bool(since)
92+
has_until = bool(until)
93+
since_is_date = has_since and (" " in since)
94+
until_is_date = has_until and (" " in until)
95+
96+
if has_since and has_until:
97+
if since_is_date or until_is_date:
98+
# Date-based range
99+
cmd.append(f"--since={since}")
100+
cmd.append(f"--until={until}")
101+
else:
102+
# Ref-based range: use {since}..{until}
103+
cmd.append(f"{since}..{until}")
104+
elif has_since:
105+
if since_is_date:
106+
cmd.append(f"--since={since}")
107+
else:
108+
cmd.append(since)
109+
elif has_until:
110+
if until_is_date:
111+
cmd.append(f"--until={until}")
112+
else:
113+
cmd.append(until)
106114

107115
try:
108116
result = subprocess.run(
@@ -117,10 +125,12 @@ def _parse_git_log(repo_path: Path, since: str, until: str) -> list[_GitCommit]:
117125
return []
118126

119127
commits: list[_GitCommit] = []
120-
for line in result.stdout.strip().split("\n"):
121-
if not line.strip():
128+
# Split on record separator — safe even when %b contains newlines
129+
for record in result.stdout.split(_GIT_RECORD_SEP):
130+
record = record.strip()
131+
if not record:
122132
continue
123-
parts = line.split(_GIT_LOG_SEP)
133+
parts = record.split(_GIT_FIELD_SEP)
124134
if len(parts) < 5:
125135
continue
126136
commit = _GitCommit(
@@ -143,6 +153,9 @@ def _parse_git_log(repo_path: Path, since: str, until: str) -> list[_GitCommit]:
143153
)
144154
c.files = [f.strip() for f in files_result.stdout.strip().split("\n") if f.strip()]
145155
except OSError:
156+
# Best-effort: if git diff-tree fails for this commit (e.g. git not
157+
# available or repository in an unexpected state), leave the files
158+
# list unchanged for this commit and continue processing others.
146159
pass
147160

148161
return commits
@@ -418,7 +431,7 @@ def capture_from_git(
418431
title=title[:120],
419432
body="\n".join(body_parts),
420433
tags=sorted(all_tags),
421-
source=f"commit:{primary.hash[:8]}+{len(group)-1}",
434+
source=f"commit:{primary.hash[:8]}+{len(group) - 1}",
422435
confidence=confidence,
423436
scope=f"repo:{repo_name}",
424437
_source_hashes=[c.hash for c in group],

src/ai_memory_protocol/cli.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def _check_rst_files(workspace_dir: str | None) -> tuple[bool, str]:
151151
return False, f"{len(errors)} unreadable files: {'; '.join(errors)}"
152152
return True, f"{len(rst_files)} RST files OK"
153153

154+
154155
# ---------------------------------------------------------------------------
155156
# Subcommands
156157
# ---------------------------------------------------------------------------
@@ -171,12 +172,12 @@ def cmd_doctor(args: argparse.Namespace) -> None:
171172
"""Run installation health checks."""
172173
ws_dir = getattr(args, "dir", None)
173174
checks = [
174-
("CLI entry point", lambda: _check_cli()),
175+
("CLI entry point", _check_cli),
175176
("Workspace exists", lambda: _check_workspace(ws_dir)),
176177
("Sphinx-build available", lambda: _check_sphinx_build(ws_dir)),
177178
("needs.json loadable", lambda: _check_needs_json(ws_dir)),
178-
("MCP SDK installed", lambda: _check_mcp_importable()),
179-
("MCP server creatable", lambda: _check_mcp_server()),
179+
("MCP SDK installed", _check_mcp_importable),
180+
("MCP server creatable", _check_mcp_server),
180181
("RST files parseable", lambda: _check_rst_files(ws_dir)),
181182
]
182183
all_ok = True

src/ai_memory_protocol/executor.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,12 @@ def validate_actions(actions: list[Action]) -> tuple[list[Action], list[dict[str
120120
visited.add(current)
121121
current = supersede_map[current]
122122
if cycle:
123-
skipped.append({
124-
"action": a.to_dict(),
125-
"reason": f"Circular supersede chain involving {a.old_id}",
126-
})
123+
skipped.append(
124+
{
125+
"action": a.to_dict(),
126+
"reason": f"Circular supersede chain involving {a.old_id}",
127+
}
128+
)
127129
continue
128130

129131
valid.append(a)
@@ -213,8 +215,7 @@ def _execute_split_file(workspace: Path, action: Action) -> tuple[bool, str]: #
213215
via rst.py append_to_rst when MAX_ENTRIES_PER_FILE is exceeded.
214216
"""
215217
return True, (
216-
f"File splitting noted for {action.rst_path}"
217-
" — handled automatically on next append."
218+
f"File splitting noted for {action.rst_path} — handled automatically on next append."
218219
)
219220

220221

@@ -243,8 +244,13 @@ def _git_stash_push(workspace: Path) -> bool:
243244
capture_output=True,
244245
text=True,
245246
)
246-
# "No local changes to save" means nothing was stashed
247-
return "No local changes" not in result.stdout
247+
# Only treat as successful if git exited cleanly.
248+
if result.returncode != 0:
249+
return False
250+
# "No local changes to save" means nothing was stashed.
251+
# This may appear in stdout or stderr.
252+
output = (result.stdout or "") + (result.stderr or "")
253+
return "No local changes to save" not in output
248254
except OSError:
249255
return False
250256

@@ -346,10 +352,12 @@ def execute_plan(
346352
for action in valid_actions:
347353
executor = _EXECUTORS.get(action.kind)
348354
if not executor:
349-
failed.append({
350-
"action": action.to_dict(),
351-
"error": f"Unknown action kind: {action.kind}",
352-
})
355+
failed.append(
356+
{
357+
"action": action.to_dict(),
358+
"error": f"Unknown action kind: {action.kind}",
359+
}
360+
)
353361
continue
354362

355363
try:
@@ -368,16 +376,28 @@ def execute_plan(
368376
if rebuild and applied:
369377
build_ok, build_output = run_rebuild(workspace)
370378

371-
# If build failed, rollback
372-
if not build_ok and stashed:
373-
_git_stash_pop(workspace)
379+
# If build failed, always treat as unsuccessful; use git stash for rollback
380+
# when available.
381+
if not build_ok:
382+
if stashed:
383+
_git_stash_pop(workspace)
384+
applied_result: list[dict[str, Any]] = []
385+
message = "Build failed after applying actions — rolled back via git stash pop."
386+
else:
387+
# No stash available: cannot automatically roll back workspace changes.
388+
applied_result = applied
389+
message = (
390+
"Build failed after applying actions — no git stash available for "
391+
"rollback; workspace may be in an inconsistent state."
392+
)
393+
374394
return ExecutionResult(
375395
success=False,
376-
applied=[],
396+
applied=applied_result,
377397
failed=failed,
378398
skipped=skipped,
379399
build_output=build_output,
380-
message="Build failed after applying actions — rolled back via git stash pop.",
400+
message=message,
381401
)
382402

383403
# Cleanup stash on success
@@ -390,15 +410,15 @@ def execute_plan(
390410
msg = f"memory: auto-apply {', '.join(sorted(kinds))} ({len(applied)} actions)"
391411
_git_commit(workspace, msg)
392412

413+
all_succeeded = not failed
393414
return ExecutionResult(
394-
success=True,
415+
success=all_succeeded,
395416
applied=applied,
396417
failed=failed,
397418
skipped=skipped,
398419
build_output=build_output,
399420
message=(
400-
f"Plan executed: {len(applied)} applied, "
401-
f"{len(failed)} failed, {len(skipped)} skipped."
421+
f"Plan executed: {len(applied)} applied, {len(failed)} failed, {len(skipped)} skipped."
402422
),
403423
)
404424

0 commit comments

Comments
 (0)