Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ sia-code index --clean

View config: `sia-code config show`

**Git worktrees:** by default, sia-code auto-detects worktrees and stores a single shared index in the git common dir. You can override with `SIA_CODE_INDEX_SCOPE` or set an explicit path with `SIA_CODE_INDEX_DIR`.

```bash
# Force shared index even outside worktrees
export SIA_CODE_INDEX_SCOPE=shared

# Or disable auto-detection (per-worktree index)
export SIA_CODE_INDEX_SCOPE=worktree

sia-code init
sia-code index .
```

**AI Summarization** (optional, enhances git changelogs):

```json
Expand Down
9 changes: 8 additions & 1 deletion docs/CLI_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ sia-code init [OPTIONS]
| `--path PATH` | Directory to initialize | `.` (current directory) |
| `--dry-run` | Preview project analysis without creating index | `false` |

**Environment variables:**

| Variable | Description |
|----------|-------------|
| `SIA_CODE_INDEX_DIR` | Override the index directory (absolute or project-relative) |
| `SIA_CODE_INDEX_SCOPE` | Set to `shared` to reuse one index across git worktrees |

**Examples:**

```bash
Expand All @@ -100,7 +107,7 @@ sia-code init --dry-run
- Finds documentation files
- Determines recommended search strategy

2. **Creates `.sia-code/` directory:**
2. **Creates index directory:**
- `config.json` - Configuration with auto-detected settings
- `vectors.usearch` - HNSW vector index (created empty)
- `index.db` - SQLite database with FTS5 (created empty)
Expand Down
3 changes: 3 additions & 0 deletions docs/MEMORY_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Memory features help teams:
- **Understand history** - What changed and when?
- **Share context** - Export/import decisions across teams
- **Maintain documentation** - Auto-generate from git history
- **Preserve timing** - Record commit hashes/timestamps for traceability

## Features

Expand Down Expand Up @@ -57,6 +58,8 @@ Auto-extracted from git commit history.
- Breaking changes
- Major refactors

Each event stores the source commit hash and timestamp for auditability.

**Example:**
```python
# Auto-extracted from git log
Expand Down
134 changes: 127 additions & 7 deletions sia_code/cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""CLI entry point for Sia Code."""

import os
import sys
import logging
import subprocess
from datetime import datetime
from pathlib import Path

import click
Expand Down Expand Up @@ -128,6 +131,117 @@ def create_backend(index_path: Path, config: Config, valid_chunks=None):
)


def resolve_git_common_dir(base_dir: Path) -> Path | None:
"""Return git common dir path if available for a repository."""
try:
result = subprocess.run(
["git", "rev-parse", "--git-common-dir"],
cwd=base_dir,
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return None

output = result.stdout.strip()
if not output:
return None

common_dir = Path(output)
if not common_dir.is_absolute():
common_dir = (base_dir / common_dir).resolve()
return common_dir


def is_git_worktree(base_dir: Path) -> bool:
"""Return True when base_dir is inside a git worktree.

In a normal repo, `--git-dir` and `--git-common-dir` resolve to the same path.
In a linked worktree, the worktree's git dir differs from the common dir.
"""

try:
git_dir_result = subprocess.run(
["git", "rev-parse", "--git-dir"],
cwd=base_dir,
check=True,
capture_output=True,
text=True,
)
common_dir_result = subprocess.run(
["git", "rev-parse", "--git-common-dir"],
cwd=base_dir,
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return False

git_dir_raw = git_dir_result.stdout.strip()
common_dir_raw = common_dir_result.stdout.strip()
if not git_dir_raw or not common_dir_raw:
return False

git_dir = Path(git_dir_raw)
if not git_dir.is_absolute():
git_dir = (base_dir / git_dir).resolve()

common_dir = Path(common_dir_raw)
if not common_dir.is_absolute():
common_dir = (base_dir / common_dir).resolve()

return git_dir != common_dir


def get_git_commit_context(base_dir: Path) -> tuple[str | None, datetime | None]:
"""Return the current git commit hash and commit time for a directory."""
try:
commit_result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=base_dir,
check=True,
capture_output=True,
text=True,
)
time_result = subprocess.run(
["git", "show", "-s", "--format=%cI", "HEAD"],
cwd=base_dir,
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return None, None

commit_hash = commit_result.stdout.strip() or None
commit_time_raw = time_result.stdout.strip()
commit_time = datetime.fromisoformat(commit_time_raw) if commit_time_raw else None
return commit_hash, commit_time


def resolve_index_dir(project_dir: Path | None = None) -> Path:
"""Resolve the index directory, honoring environment overrides."""
base_dir = project_dir or Path(".")
override = os.environ.get("SIA_CODE_INDEX_DIR")
if override:
override_path = Path(override)
if override_path.is_absolute():
return override_path
return base_dir / override_path

scope = os.environ.get("SIA_CODE_INDEX_SCOPE")
if not scope or scope == "auto":
scope = "shared" if is_git_worktree(base_dir) else "worktree"
if scope == "shared":
common_dir = resolve_git_common_dir(base_dir)
if common_dir is not None:
return common_dir / "sia-code"

return base_dir / ".sia-code"


def require_initialized() -> tuple[Path, Config]:
"""Ensure Sia Code is initialized, return sia_dir and config.

Expand All @@ -137,7 +251,7 @@ def require_initialized() -> tuple[Path, Config]:
Raises:
SystemExit: If .sia-code directory doesn't exist
"""
sia_dir = Path(".sia-code")
sia_dir = resolve_index_dir()
if not sia_dir.exists():
console.print("[red]Error: Sia Code not initialized. Run 'sia-code init' first.[/red]")
sys.exit(1)
Expand All @@ -164,7 +278,7 @@ def init(path: str, dry_run: bool):
from .indexer.project_analyzer import ProjectAnalyzer

project_dir = Path(path)
sia_dir = project_dir / ".sia-code"
sia_dir = resolve_index_dir(project_dir)

if sia_dir.exists() and not dry_run:
console.print(f"[yellow]Sia Code already initialized at {sia_dir}[/yellow]")
Expand All @@ -190,7 +304,7 @@ def init(path: str, dry_run: bool):
console.print("\n[yellow]Dry run complete. No index created.[/yellow]")
return

# Create .sia-code directory
# Create index directory
sia_dir.mkdir(parents=True, exist_ok=True)
(sia_dir / "cache").mkdir(exist_ok=True)

Expand Down Expand Up @@ -1413,12 +1527,16 @@ def memory_add_decision(title, description, reasoning, alternatives):

session_id = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")

commit_hash, commit_time = get_git_commit_context(Path("."))

decision_id = backend.add_decision(
session_id=session_id,
title=title,
description=description,
reasoning=reasoning,
alternatives=alt_list,
commit_hash=commit_hash,
commit_time=commit_time,
)

console.print(f"[green]✓[/green] Created decision #{decision_id}: {title}")
Expand Down Expand Up @@ -1821,9 +1939,7 @@ def memory_changelog(range, output_format, output):


@memory.command(name="export")
@click.option(
"-o", "--output", type=click.Path(), default=".sia-code/memory.json", help="Output file"
)
@click.option("-o", "--output", type=click.Path(), default=None, help="Output file")
def memory_export(output):
"""Export memory to JSON file.

Expand All @@ -1834,6 +1950,8 @@ def memory_export(output):
backend.open_index()

try:
if output is None:
output = str(sia_dir / "memory.json")
export_path = backend.export_memory(include_pending=True)

# Copy to specified output location if different
Expand All @@ -1855,7 +1973,7 @@ def memory_export(output):
"--input",
"input_file",
type=click.Path(exists=True),
default=".sia-code/memory.json",
default=None,
help="Input file",
)
def memory_import(input_file):
Expand All @@ -1868,6 +1986,8 @@ def memory_import(input_file):
backend.open_index(writable=True)

try:
if input_file is None:
input_file = str(sia_dir / "memory.json")
result = backend.import_memory(input_file)

console.print("[green]✓[/green] Import complete")
Expand Down
12 changes: 12 additions & 0 deletions sia_code/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ class Decision:
alternatives: list[dict[str, Any]] = field(default_factory=list)
status: str = "pending" # 'pending', 'approved', 'rejected'
category: str | None = None # Set when approved
commit_hash: str | None = None
commit_time: datetime | None = None
created_at: datetime | None = None
approved_at: datetime | None = None

Expand All @@ -180,6 +182,8 @@ def to_dict(self) -> dict[str, Any]:
"alternatives": self.alternatives,
"status": self.status,
"category": self.category,
"commit_hash": self.commit_hash,
"commit_time": self.commit_time.isoformat() if self.commit_time else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"approved_at": self.approved_at.isoformat() if self.approved_at else None,
}
Expand All @@ -197,6 +201,8 @@ class TimelineEvent:
files_changed: list[str] = field(default_factory=list)
diff_stats: dict[str, Any] = field(default_factory=dict)
importance: str = "medium" # 'high', 'medium', 'low'
commit_hash: str | None = None
commit_time: datetime | None = None
created_at: datetime | None = None

def to_dict(self) -> dict[str, Any]:
Expand All @@ -210,6 +216,8 @@ def to_dict(self) -> dict[str, Any]:
"files_changed": self.files_changed,
"diff_stats": self.diff_stats,
"importance": self.importance,
"commit_hash": self.commit_hash,
"commit_time": self.commit_time.isoformat() if self.commit_time else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
}

Expand All @@ -226,6 +234,8 @@ class ChangelogEntry:
breaking_changes: list[str] = field(default_factory=list)
features: list[str] = field(default_factory=list)
fixes: list[str] = field(default_factory=list)
commit_hash: str | None = None
commit_time: datetime | None = None
created_at: datetime | None = None

def to_dict(self) -> dict[str, Any]:
Expand All @@ -239,6 +249,8 @@ def to_dict(self) -> dict[str, Any]:
"breaking_changes": self.breaking_changes,
"features": self.features,
"fixes": self.fixes,
"commit_hash": self.commit_hash,
"commit_time": self.commit_time.isoformat() if self.commit_time else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
}

Expand Down
Loading
Loading