Skip to content

Commit 5ce0af7

Browse files
feat: Make snapshot functionality project-aware
This commit refactors the snapshot functionality to be project-aware. Snapshots are now stored in project-specific subdirectories under `.codesage/snapshots/`. The following commands have been updated to support project-specific snapshots: - `snapshot` - `diff` - `governance-plan` - `llm-suggest` - `jules-prompt` These commands now require a `--project` option to specify the project context. The `snapshot create` command can also override the project name with an optional `--project` flag. The `history-*` commands were investigated and found to be using a separate, legacy snapshot system that is already project-aware. Therefore, they were not modified. Additionally, the repository has been cleaned up by removing temporary test files and adding them to the `.gitignore` file. Test fixtures have been moved to the `tests/fixtures` directory.
1 parent e6faf13 commit 5ce0af7

File tree

13 files changed

+263
-73
lines changed

13 files changed

+263
-73
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,7 @@ dmypy.json
121121
.codesage/
122122
report.md
123123
report.json
124+
codesage.db
125+
plan.yml
126+
task.yml
127+
enriched_snapshot.yml

codesage.db

-32 KB
Binary file not shown.

codesage/cli/commands/diff.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717
@click.command()
1818
@click.argument('version1')
1919
@click.argument('version2')
20+
@click.option('--project', '-p', required=True, help='The name of the project.')
2021
@click.option('--output', '-o', type=click.Path(), help='Output file for the diff report.')
2122
@click.option('--format', '-f', type=click.Choice(['json', 'markdown']), default='json', help='Output format.')
22-
def diff(version1, version2, output, format):
23+
def diff(version1, version2, project, output, format):
2324
"""
2425
Compare two snapshots and show the differences.
2526
"""
26-
manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_CONFIG['snapshot'])
27+
manager = SnapshotVersionManager(SNAPSHOT_DIR, project, DEFAULT_CONFIG['snapshot'])
2728

2829
from codesage.snapshot.models import ProjectSnapshot
2930
import json

codesage/cli/commands/governance_plan.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,21 @@
1010
from codesage.audit.models import AuditEvent
1111
from datetime import datetime
1212

13+
from codesage.snapshot.versioning import SnapshotVersionManager
14+
from codesage.config.defaults import SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG
15+
1316
@click.command(name="governance-plan", help="Generate a governance plan from a project snapshot.")
1417
@click.option(
15-
"--input",
16-
"input_path",
18+
"--snapshot-version",
19+
"snapshot_version",
20+
required=True,
21+
help="The version of the snapshot to use.",
22+
)
23+
@click.option(
24+
"--project",
25+
"project_name",
1726
required=True,
18-
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
19-
help="Path to the input project snapshot YAML file.",
27+
help="The name of the project.",
2028
)
2129
@click.option(
2230
"--output",
@@ -30,21 +38,22 @@
3038
@click.pass_context
3139
def governance_plan(
3240
ctx,
33-
input_path: str,
41+
snapshot_version: str,
42+
project_name: str,
3443
output_path: str,
3544
group_by: str | None,
3645
max_tasks_per_file: int | None,
3746
):
3847
"""
39-
Generates a governance plan from a project snapshot YAML file.
48+
Generates a governance plan from a project snapshot.
4049
"""
4150
audit_logger = ctx.obj.audit_logger
42-
project_name = None
4351
try:
44-
click.echo(f"Loading snapshot from {input_path}...")
45-
snapshot_data = read_yaml_file(Path(input_path))
46-
snapshot = ProjectSnapshot.model_validate(snapshot_data)
47-
project_name = snapshot.metadata.project_name
52+
manager = SnapshotVersionManager(SNAPSHOT_DIR, project_name, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
53+
snapshot = manager.load_snapshot(snapshot_version)
54+
if not snapshot:
55+
click.echo(f"Snapshot {snapshot_version} not found for project '{project_name}'.", err=True)
56+
return
4857

4958
# Apply config overrides
5059
config = GovernanceConfig.default()
@@ -74,7 +83,7 @@ def governance_plan(
7483
project_name=project_name,
7584
command="governance-plan",
7685
args={
77-
"input_path": input_path,
86+
"snapshot_version": snapshot_version,
7887
"output_path": output_path,
7988
"group_by": group_by,
8089
"max_tasks_per_file": max_tasks_per_file,

codesage/cli/commands/jules_prompt.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@
1313
from codesage.audit.models import AuditEvent
1414
from datetime import datetime
1515

16+
from codesage.snapshot.versioning import SnapshotVersionManager
17+
from codesage.config.defaults import SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG
18+
1619
@click.command('jules-prompt', help="Generate a Jules prompt for a specific governance task.")
1720
@click.option('--plan', 'plan_path', type=click.Path(exists=True), help="Path to the governance_plan.yaml file.")
1821
@click.option('--task-id', help="The ID of the task within the governance plan.")
1922
@click.option('--task', 'task_path', type=click.Path(exists=True), help="Path to a single GovernanceTask YAML/JSON file.")
23+
@click.option('--project', '-p', 'project_name', type=str, help='The name of the project.')
24+
@click.option('--snapshot-version', '-s', 'snapshot_version', type=str, help='The version of the snapshot to use.')
2025
@click.pass_context
21-
def jules_prompt(ctx, plan_path: Optional[str], task_id: Optional[str], task_path: Optional[str]):
26+
def jules_prompt(ctx, plan_path: Optional[str], task_id: Optional[str], task_path: Optional[str], project_name: Optional[str], snapshot_version: Optional[str]):
2227
"""
2328
Generates a prompt for Jules from a governance task.
2429
"""
@@ -60,18 +65,32 @@ def jules_prompt(ctx, plan_path: Optional[str], task_id: Optional[str], task_pat
6065
click.echo(f"Error: Template with ID '{recipe.template_id}' not found.", err=True)
6166
return
6267

63-
# The JulesTaskView requires a code snippet, which we don't have in the GovernanceTask alone.
64-
# For this CLI tool, we'll use a placeholder. A more advanced implementation
65-
# would need access to the project snapshot to get the real code.
66-
# We will use the `llm_hint` field from the task metadata if it exists.
68+
snapshot = None
69+
if project_name and snapshot_version:
70+
manager = SnapshotVersionManager(SNAPSHOT_DIR, project_name, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
71+
snapshot = manager.load_snapshot(snapshot_version)
72+
if not snapshot:
73+
click.echo(f"Warning: Snapshot {snapshot_version} not found for project '{project_name}'. Code snippet will not be available.", err=True)
6774

68-
code_snippet_placeholder = task.metadata.get("code_snippet", "[Code snippet not available in this context. Please refer to the file and line number.]")
75+
code_snippet = "[Code snippet not available in this context. Please refer to the file and line number.]"
76+
if snapshot:
77+
for file_snapshot in snapshot.files:
78+
if file_snapshot.path == task.file_path:
79+
try:
80+
with open(file_snapshot.path, 'r', encoding='utf-8') as f:
81+
lines = f.readlines()
82+
start_line = task.metadata.get("start_line", 1) - 1
83+
end_line = task.metadata.get("end_line", start_line + 10)
84+
code_snippet = "".join(lines[start_line:end_line])
85+
except Exception as e:
86+
click.echo(f"Warning: Could not read file {file_snapshot.path}: {e}", err=True)
87+
break
6988

7089
# Create a simple JulesTaskView from the task
7190
view = JulesTaskView(
7291
file_path=task.file_path,
7392
language=task.language,
74-
code_snippet=code_snippet_placeholder,
93+
code_snippet=code_snippet,
7594
issue_message=task.description,
7695
goal_description=task.metadata.get("goal_description", "Fix the issue described above."),
7796
line=task.metadata.get("start_line"),
@@ -94,6 +113,8 @@ def jules_prompt(ctx, plan_path: Optional[str], task_id: Optional[str], task_pat
94113
"plan_path": plan_path,
95114
"task_id": task_id,
96115
"task_path": task_path,
116+
"project_name": project_name,
117+
"snapshot_version": snapshot_version,
97118
},
98119
)
99120
)

codesage/cli/commands/llm_suggest.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,23 @@
88
from codesage.snapshot.yaml_generator import YAMLGenerator
99

1010

11+
from codesage.snapshot.versioning import SnapshotVersionManager
12+
from codesage.config.defaults import SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG
13+
1114
@click.command('llm-suggest')
12-
@click.option('--input', '-i', 'input_path', type=click.Path(exists=True, dir_okay=False), required=True, help='Input snapshot YAML file.')
15+
@click.option('--snapshot-version', '-s', 'snapshot_version', type=str, required=True, help='The version of the snapshot to use.')
16+
@click.option('--project', '-p', 'project_name', type=str, required=True, help='The name of the project.')
1317
@click.option('--output', '-o', 'output_path', type=click.Path(), required=True, help='Output snapshot YAML file.')
1418
@click.option('--provider', type=click.Choice(['dummy']), default='dummy', help='LLM provider to use.')
1519
@click.option('--model', type=str, default='dummy-model', help='LLM model to use.')
16-
def llm_suggest(input_path, output_path, provider, model):
20+
def llm_suggest(snapshot_version, project_name, output_path, provider, model):
1721
"""Enrich a snapshot with LLM-powered suggestions."""
1822

19-
with open(input_path, 'r') as f:
20-
snapshot_data = yaml.safe_load(f)
21-
22-
project_snapshot = ProjectSnapshot.model_validate(snapshot_data)
23+
manager = SnapshotVersionManager(SNAPSHOT_DIR, project_name, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
24+
project_snapshot = manager.load_snapshot(snapshot_version)
25+
if not project_snapshot:
26+
click.echo(f"Snapshot {snapshot_version} not found for project '{project_name}'.", err=True)
27+
return
2328

2429
if provider == 'dummy':
2530
llm_client = DummyLLMClient()

codesage/cli/commands/snapshot.py

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
import gzip
55
import hashlib
6-
from datetime import datetime
6+
from datetime import datetime, timezone
77
from pathlib import Path
88

99
from codesage.snapshot.versioning import SnapshotVersionManager
@@ -46,7 +46,7 @@ def snapshot():
4646
from codesage.audit.models import AuditEvent
4747

4848

49-
def _create_snapshot_data(path):
49+
def _create_snapshot_data(path, project_name):
5050
file_snapshots = []
5151
for root, dirs, files in os.walk(path):
5252
dirs[:] = [d for d in dirs if d not in DEFAULT_EXCLUDE_DIRS]
@@ -81,7 +81,7 @@ def _create_snapshot_data(path):
8181
metadata=SnapshotMetadata(
8282
version="",
8383
timestamp=datetime.now(),
84-
project_name=os.path.basename(os.path.abspath(path)),
84+
project_name=project_name,
8585
file_count=len(file_snapshots),
8686
total_size=total_size,
8787
tool_version=tool_version,
@@ -97,15 +97,16 @@ def _create_snapshot_data(path):
9797

9898
@snapshot.command('create')
9999
@click.argument('path', type=click.Path(exists=True, dir_okay=True))
100+
@click.option('--project', '-p', 'project_name_override', help='Override the project name.')
100101
@click.option('--format', '-f', type=click.Choice(['json', 'python-semantic-digest']), default='json', help='Snapshot format.')
101102
@click.option('--output', '-o', type=click.Path(), default=None, help='Output file path.')
102103
@click.option('--compress', is_flag=True, help='Enable compression.')
103104
@click.option('--language', '-l', type=click.Choice(['python', 'go', 'shell', 'java', 'auto']), default='auto', help='Language to analyze.')
104105
@click.pass_context
105-
def create(ctx, path, format, output, compress, language):
106+
def create(ctx, path, project_name_override, format, output, compress, language):
106107
"""Create a new snapshot from the given path."""
107108
audit_logger = ctx.obj.audit_logger
108-
project_name = os.path.basename(os.path.abspath(path))
109+
project_name = project_name_override or os.path.basename(os.path.abspath(path))
109110
try:
110111
root_path = Path(path)
111112

@@ -168,31 +169,38 @@ def create(ctx, path, format, output, compress, language):
168169
click.echo(f"{language.capitalize()} semantic digest created at {output}")
169170
return
170171

171-
snapshot_data = _create_snapshot_data(path)
172+
snapshot_data = _create_snapshot_data(path, project_name)
172173

173174
if output:
174175
output_path = Path(output)
175176
output_path.parent.mkdir(parents=True, exist_ok=True)
176-
with open(output_path, 'w') as f:
177+
# Use model_dump_json for consistency
178+
with open(output_path, 'w', encoding='utf-8') as f:
177179
f.write(snapshot_data.model_dump_json(indent=2))
178180

179181
click.echo(f"Snapshot created at {output}")
180182
else:
181-
manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
183+
manager = SnapshotVersionManager(SNAPSHOT_DIR, project_name, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
184+
185+
# The format for saving via manager is 'json', not the input format for semantic digests
186+
save_format = 'json'
187+
182188
if compress:
183-
snapshot_path = manager.save_snapshot(snapshot_data, format)
189+
snapshot_path = manager.save_snapshot(snapshot_data, save_format)
190+
191+
# Compress the file
184192
with open(snapshot_path, 'rb') as f_in:
185193
with gzip.open(f"{snapshot_path}.gz", 'wb') as f_out:
186194
f_out.writelines(f_in)
187195
os.remove(snapshot_path)
188196
click.echo(f"Compressed snapshot created at {snapshot_path}.gz")
189197
else:
190-
snapshot_path = manager.save_snapshot(snapshot_data, format)
198+
snapshot_path = manager.save_snapshot(snapshot_data, save_format)
191199
click.echo(f"Snapshot created at {snapshot_path}")
192200
finally:
193201
audit_logger.log(
194202
AuditEvent(
195-
timestamp=datetime.now(),
203+
timestamp=datetime.now(timezone.utc),
196204
event_type="cli.snapshot.create",
197205
project_name=project_name,
198206
command="snapshot create",
@@ -207,56 +215,52 @@ def create(ctx, path, format, output, compress, language):
207215
)
208216

209217
@snapshot.command('list')
210-
def list_snapshots():
211-
"""List all available snapshots."""
212-
manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
218+
@click.option('--project', '-p', required=True, help='The name of the project.')
219+
def list_snapshots(project):
220+
"""List all available snapshots for a project."""
221+
manager = SnapshotVersionManager(SNAPSHOT_DIR, project, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
213222
snapshots = manager.list_snapshots()
214223
if not snapshots:
215-
click.echo("No snapshots found.")
224+
click.echo(f"No snapshots found for project '{project}'.")
216225
return
217226
for s in snapshots:
218227
click.echo(f"- {s['version']} ({s['timestamp']})")
219228

220229
@snapshot.command('show')
221230
@click.argument('version')
222-
def show(version):
231+
@click.option('--project', '-p', required=True, help='The name of the project.')
232+
def show(version, project):
223233
"""Show details of a specific snapshot."""
224-
manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
234+
manager = SnapshotVersionManager(SNAPSHOT_DIR, project, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
225235
snapshot_data = manager.load_snapshot(version)
226236
if not snapshot_data:
227-
click.echo(f"Snapshot {version} not found.", err=True)
237+
click.echo(f"Snapshot {version} not found for project '{project}'.", err=True)
228238
return
229239
click.echo(snapshot_data.model_dump_json(indent=2))
230240

231241
@snapshot.command('cleanup')
242+
@click.option('--project', '-p', required=True, help='The name of the project.')
232243
@click.option('--dry-run', is_flag=True, help='Show which snapshots would be deleted.')
233-
def cleanup(dry_run):
234-
"""Clean up old snapshots."""
235-
from datetime import timedelta
236-
237-
manager = SnapshotVersionManager(SNAPSHOT_DIR, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
244+
def cleanup(project, dry_run):
245+
"""Clean up old snapshots for a project."""
246+
manager = SnapshotVersionManager(SNAPSHOT_DIR, project, DEFAULT_SNAPSHOT_CONFIG['snapshot'])
238247

239248
if dry_run:
240249
index = manager._load_index()
241-
now = datetime.now()
242-
243-
expired_by_date = [
244-
s for s in index
245-
if now - datetime.fromisoformat(s["timestamp"]) > timedelta(days=manager.retention_days)
246-
]
247-
248-
sorted_by_date = sorted(index, key=lambda s: s["timestamp"], reverse=True)
249-
expired_by_count = sorted_by_date[manager.max_versions:]
250+
if not index:
251+
click.echo(f"No snapshots to clean up for project '{project}'.")
252+
return
250253

251-
expired = {s['version']: s for s in expired_by_date + expired_by_count}.values()
254+
now = datetime.now(timezone.utc)
255+
expired_snapshots = manager._get_expired_snapshots(index, now)
252256

253-
if not expired:
254-
click.echo("No snapshots to clean up.")
257+
if not expired_snapshots:
258+
click.echo(f"No snapshots to clean up for project '{project}'.")
255259
return
256260

257261
click.echo("Snapshots to be deleted:")
258-
for s in expired:
262+
for s in expired_snapshots:
259263
click.echo(f"- {s['version']}")
260264
else:
261265
manager.cleanup_expired_snapshots()
262-
click.echo("Expired snapshots have been cleaned up.")
266+
click.echo(f"Expired snapshots for project '{project}' have been cleaned up.")

0 commit comments

Comments
 (0)