Skip to content

Commit c728308

Browse files
committed
init: auto-create / update .gitignore with a managed solo-mise block
The handoff inbox is session-local. Files there may contain private context (commands run, paths, error strings) and should not propagate through git. Only TEMPLATE.md is meant to be tracked. `solo-mise init` now creates or updates `.gitignore` in the target with a marker-bounded block that excludes: - .claude/memory-handoffs/* (except TEMPLATE.md and .gitkeep) - memory/YYYY-MM-DD.md daily session logs - memory/handoff-inbox/ review-state directory - .solo-mise/logs/ and .solo-mise/scrub-cache/ Idempotency: re-running init replaces only the content between the `>>> solo-mise gitignore block >>>` and `<<< solo-mise gitignore block <<<` markers. Rules outside the block are preserved. Tampered block content is restored on re-run. CLI: --no-gitignore opts out. Dry-run prints the gitignore action without writing. 8 new tests cover create-from-scratch, append-to-existing, idempotent replace, preservation of out-of-block content, --no-gitignore skip, dry-run no-write, and workspace profile coverage. Also: drop the wholesale `.claude/` exclude from solo-mise's own gitignore so the dogfooded TEMPLATE.md gets tracked (the new managed block handles handoff privacy correctly). Total tests: 50 passing.
1 parent 8d888b0 commit c728308

5 files changed

Lines changed: 211 additions & 2 deletions

File tree

.gitignore

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,35 @@ htmlcov/
3131
.DS_Store
3232
Thumbs.db
3333

34-
# claude / agent local state
35-
.claude/
34+
# claude / agent local state (side-harness only; solo-mise block below
35+
# handles the handoff path with TEMPLATE.md kept tracked)
3636
.codex/
3737
.openclaw/
3838

3939
# scratch
4040
/scratch/
4141
*.bak
4242
*.tmp
43+
44+
# >>> solo-mise gitignore block >>>
45+
# Managed by `solo-mise init`. Edit between the markers to customize.
46+
# Re-running `solo-mise init` replaces only the content between markers.
47+
48+
# Memory handoffs are session-local and may contain private context
49+
# (commands run, file paths, error strings). The TEMPLATE.md is the
50+
# only handoff file tracked in git.
51+
.claude/memory-handoffs/*
52+
!.claude/memory-handoffs/TEMPLATE.md
53+
!.claude/memory-handoffs/.gitkeep
54+
55+
# Daily session logs are machine-local raw context. Promote durable
56+
# findings into memory/cards/ via the handoff flow instead.
57+
memory/20[0-9][0-9]-[0-1][0-9]-[0-3][0-9].md
58+
59+
# Review inbox: ambiguous handoffs awaiting human triage. Private.
60+
memory/handoff-inbox/
61+
62+
# solo-mise local state (logs, scrub cache).
63+
.solo-mise/logs/
64+
.solo-mise/scrub-cache/
65+
# <<< solo-mise gitignore block <<<

QUICKSTART.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ your-project/
4242
TEMPLATE.md
4343
hooks/
4444
pre-push
45+
.gitignore # adds a managed solo-mise block (handoffs, daily logs, review inbox)
4546
```
4647

48+
The `.gitignore` block is bounded by `# >>> solo-mise gitignore block >>>` markers so re-running `init` is idempotent and your hand-written rules outside the block are preserved. Pass `--no-gitignore` to skip the gitignore step.
49+
4750
Enable the pre-push hook once:
4851

4952
```bash

src/solo_mise/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ def _build_parser() -> argparse.ArgumentParser:
4040
action="store_true",
4141
help="Override the safety guard that refuses to install directly into $HOME.",
4242
)
43+
p_init.add_argument(
44+
"--no-gitignore",
45+
dest="update_gitignore",
46+
action="store_false",
47+
default=True,
48+
help="Do not create or update the target's .gitignore.",
49+
)
4350
p_init.add_argument("--dry-run", action="store_true", help="Show what would happen.")
4451

4552
# doctor
@@ -112,6 +119,7 @@ def main(argv=None) -> int:
112119
dry_run=args.dry_run,
113120
harness=args.harness,
114121
allow_home=args.allow_home,
122+
update_gitignore=args.update_gitignore,
115123
)
116124
if cmd == "doctor":
117125
from . import doctor as doctor_mod

src/solo_mise/init.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import os
5+
import re
56
import shutil
67
import sys
78
from pathlib import Path, PurePosixPath
@@ -16,13 +17,42 @@
1617
)
1718

1819

20+
GITIGNORE_BEGIN = "# >>> solo-mise gitignore block >>>"
21+
GITIGNORE_END = "# <<< solo-mise gitignore block <<<"
22+
23+
GITIGNORE_BLOCK = f"""{GITIGNORE_BEGIN}
24+
# Managed by `solo-mise init`. Edit between the markers to customize.
25+
# Re-running `solo-mise init` replaces only the content between markers.
26+
27+
# Memory handoffs are session-local and may contain private context
28+
# (commands run, file paths, error strings). The TEMPLATE.md is the
29+
# only handoff file tracked in git.
30+
.claude/memory-handoffs/*
31+
!.claude/memory-handoffs/TEMPLATE.md
32+
!.claude/memory-handoffs/.gitkeep
33+
34+
# Daily session logs are machine-local raw context. Promote durable
35+
# findings into memory/cards/ via the handoff flow instead.
36+
memory/20[0-9][0-9]-[0-1][0-9]-[0-3][0-9].md
37+
38+
# Review inbox: ambiguous handoffs awaiting human triage. Private.
39+
memory/handoff-inbox/
40+
41+
# solo-mise local state (logs, scrub cache).
42+
.solo-mise/logs/
43+
.solo-mise/scrub-cache/
44+
{GITIGNORE_END}
45+
"""
46+
47+
1948
def run(
2049
target: Path,
2150
profile_id: str = "repo",
2251
force: bool = False,
2352
dry_run: bool = False,
2453
harness: str | None = None,
2554
allow_home: bool = False,
55+
update_gitignore: bool = True,
2656
) -> int:
2757
"""Materialize `profile_id` into `target`. Returns process exit code."""
2858
target = target.expanduser().resolve()
@@ -79,6 +109,10 @@ def run(
79109
print(f" dir {target / d}")
80110
for entry in files:
81111
print(f" file {target / entry['dst']}")
112+
if update_gitignore:
113+
gi = target / ".gitignore"
114+
verb = "update" if gi.exists() else "create"
115+
print(f" gitignore {verb} {gi} with solo-mise block")
82116
return 0
83117

84118
target.mkdir(parents=True, exist_ok=True)
@@ -117,6 +151,12 @@ def run(
117151
if mode_str:
118152
dst.chmod(int(mode_str, 8))
119153

154+
# Update or create .gitignore with the solo-mise block.
155+
if update_gitignore:
156+
result = _apply_gitignore(target / ".gitignore")
157+
if result:
158+
print(f"solo-mise: gitignore {result}")
159+
120160
# Post-install notes.
121161
print(f"solo-mise: installed profile '{profile_id}' to {target}")
122162
print(f"solo-mise: memory owner -> {memory_owner_name}")
@@ -142,3 +182,35 @@ def _ensure_safe_rel(raw: str, label: str) -> None:
142182
raise ValueError(f"{label}: absolute paths not allowed: {raw!r}")
143183
if any(part == ".." for part in p.parts):
144184
raise ValueError(f"{label}: parent-dir segments not allowed: {raw!r}")
185+
186+
187+
def _apply_gitignore(path: Path) -> str:
188+
"""Create or update `.gitignore` with the solo-mise block.
189+
190+
Idempotent: a re-run replaces only the content between
191+
`GITIGNORE_BEGIN` and `GITIGNORE_END`. Returns a short status word
192+
describing what changed (`created`, `appended`, `updated`, `unchanged`).
193+
"""
194+
if not path.exists():
195+
path.write_text(GITIGNORE_BLOCK)
196+
return f"created {path}"
197+
198+
existing = path.read_text()
199+
block = GITIGNORE_BLOCK.rstrip("\n")
200+
201+
if GITIGNORE_BEGIN in existing and GITIGNORE_END in existing:
202+
# Replace existing block.
203+
pattern = re.compile(
204+
re.escape(GITIGNORE_BEGIN) + r".*?" + re.escape(GITIGNORE_END),
205+
re.DOTALL,
206+
)
207+
new = pattern.sub(block, existing)
208+
if new == existing:
209+
return f"unchanged {path}"
210+
path.write_text(new)
211+
return f"updated {path}"
212+
213+
# No block present: append. Make sure we land after exactly one blank line.
214+
sep = "" if existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n")
215+
path.write_text(existing + sep + block + "\n")
216+
return f"appended to {path}"

tests/test_gitignore.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Tests for the auto-gitignore behavior of `solo-mise init`."""
2+
from __future__ import annotations
3+
4+
from pathlib import Path
5+
6+
from solo_mise import init as init_mod
7+
8+
9+
def _read_gi(target: Path) -> str:
10+
return (target / ".gitignore").read_text()
11+
12+
13+
def test_init_creates_gitignore_when_missing(tmp_target: Path):
14+
rc = init_mod.run(target=tmp_target, profile_id="repo")
15+
assert rc == 0
16+
gi = _read_gi(tmp_target)
17+
assert init_mod.GITIGNORE_BEGIN in gi
18+
assert init_mod.GITIGNORE_END in gi
19+
assert ".claude/memory-handoffs/*" in gi
20+
assert "!.claude/memory-handoffs/TEMPLATE.md" in gi
21+
assert "memory/handoff-inbox/" in gi
22+
23+
24+
def test_init_appends_block_to_existing_gitignore(tmp_target: Path):
25+
tmp_target.mkdir()
26+
pre_existing = "# project rules\n*.log\n.env\n"
27+
(tmp_target / ".gitignore").write_text(pre_existing)
28+
rc = init_mod.run(target=tmp_target, profile_id="repo")
29+
assert rc == 0
30+
gi = _read_gi(tmp_target)
31+
assert pre_existing.strip() in gi, "should preserve existing rules"
32+
assert init_mod.GITIGNORE_BEGIN in gi
33+
assert init_mod.GITIGNORE_END in gi
34+
# block appears exactly once
35+
assert gi.count(init_mod.GITIGNORE_BEGIN) == 1
36+
assert gi.count(init_mod.GITIGNORE_END) == 1
37+
38+
39+
def test_init_idempotent_replaces_block(tmp_target: Path):
40+
rc = init_mod.run(target=tmp_target, profile_id="repo")
41+
assert rc == 0
42+
first = _read_gi(tmp_target)
43+
44+
# Tamper with the block to confirm replacement happens, not append.
45+
tampered = first.replace(".claude/memory-handoffs/*", "GARBAGE_LINE")
46+
(tmp_target / ".gitignore").write_text(tampered)
47+
48+
rc = init_mod.run(target=tmp_target, profile_id="repo", force=True)
49+
assert rc == 0
50+
second = _read_gi(tmp_target)
51+
assert ".claude/memory-handoffs/*" in second
52+
assert "GARBAGE_LINE" not in second
53+
# still exactly one block
54+
assert second.count(init_mod.GITIGNORE_BEGIN) == 1
55+
56+
57+
def test_init_preserves_user_edits_outside_block(tmp_target: Path):
58+
tmp_target.mkdir()
59+
pre = "node_modules/\n# user rules\n*.swp\n"
60+
(tmp_target / ".gitignore").write_text(pre)
61+
init_mod.run(target=tmp_target, profile_id="repo")
62+
# Add user content AFTER the block; re-run should keep it.
63+
gi_text = _read_gi(tmp_target)
64+
gi_text += "\n# after block\n.local-cache/\n"
65+
(tmp_target / ".gitignore").write_text(gi_text)
66+
67+
init_mod.run(target=tmp_target, profile_id="repo", force=True)
68+
final = _read_gi(tmp_target)
69+
assert "node_modules/" in final
70+
assert "*.swp" in final
71+
assert ".local-cache/" in final
72+
73+
74+
def test_no_gitignore_flag_skips_creation(tmp_target: Path):
75+
rc = init_mod.run(target=tmp_target, profile_id="repo", update_gitignore=False)
76+
assert rc == 0
77+
assert not (tmp_target / ".gitignore").exists()
78+
79+
80+
def test_no_gitignore_flag_leaves_existing_alone(tmp_target: Path):
81+
tmp_target.mkdir()
82+
pre = "# only my rules\n*.tmp\n"
83+
(tmp_target / ".gitignore").write_text(pre)
84+
rc = init_mod.run(
85+
target=tmp_target, profile_id="repo", update_gitignore=False
86+
)
87+
assert rc == 0
88+
assert _read_gi(tmp_target) == pre
89+
90+
91+
def test_dry_run_does_not_write_gitignore(tmp_target: Path):
92+
rc = init_mod.run(target=tmp_target, profile_id="repo", dry_run=True)
93+
assert rc == 0
94+
assert not (tmp_target / ".gitignore").exists()
95+
assert not tmp_target.exists()
96+
97+
98+
def test_workspace_profile_also_adds_block(tmp_target: Path):
99+
rc = init_mod.run(target=tmp_target, profile_id="workspace")
100+
assert rc == 0
101+
gi = _read_gi(tmp_target)
102+
assert init_mod.GITIGNORE_BEGIN in gi
103+
assert "memory/handoff-inbox/" in gi

0 commit comments

Comments
 (0)