Skip to main content

#!/usr/bin/env python3 """ Session Log Git Sync - Machine-Partitioned Git Backup

Syncs session logs to a dedicated GitHub repository with machine-based partitioning for multi-machine, multi-project development environments.

Architecture (ADR-155 Project-Scoped): SSOT: ~/.coditect-data/session-logs/projects/{project_id}/{machine_uuid}/ └── SESSION-LOG-YYYY-MM-DD.md

Git repo: ~/.coditect-data/session-logs-git/
└── machines/{machine_uuid}/
└── projects/{project_id}/SESSION-LOG-*.md

Usage: # Initialize git repo (first time) python3 session-log-git-sync.py --init

# Sync ALL logs (legacy + all projects)
python3 session-log-git-sync.py --sync

# Sync specific project only
python3 session-log-git-sync.py --sync --project PILOT

# Preview sync
python3 session-log-git-sync.py --sync --dry-run

# Pull latest from all machines
python3 session-log-git-sync.py --pull

# View status
python3 session-log-git-sync.py --status

# List all machines with logs
python3 session-log-git-sync.py --list-machines

Author: CODITECT Team Version: 4.0.0 Created: 2026-01-24 Updated: 2026-02-08 ADR: ADR-058 (Machine-Specific), ADR-114 (User Data), ADR-155 (Project-Scoped), ADR-159 (Multi-Tenant) J.27.3: Multi-tenant isolation (permissions, tenant filter, PII redaction) """

import argparse import json import os import re import shutil import stat import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional, Set, Tuple

ADR-114, ADR-118, ADR-159: User data paths with project-scoped routing

Session logs are user data, stored in .coditect-data (not framework install)

GITHUB_REPO = "git@github.com:coditect-ai/coditect-core-sessions-logs.git" GITHUB_REPO_HTTPS = "https://github.com/coditect-ai/coditect-core-sessions-logs.git"

ADR-159: Project-scoped repo routing and multi-tenant isolation

try: from scripts.core.scope import resolve_scope, get_routing_config, resolve_route, ScopeContext HAS_SCOPE = True except ImportError: HAS_SCOPE = False

J.27.3.4: PII patterns for customer data redaction check

PII_PATTERNS = [ re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Z|a-z]{2,}\b'), # email re.compile(r'\b\d{3}[-.]?\d{2}[-.]?\d{4}\b'), # SSN-like re.compile(r'\b(?:+?1[-.\s]?)?(?\d{3})?[-.\s]?\d{3}[-.\s]?\d{4}\b'), # US phone re.compile(r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b'), # credit card-like ]

Allowlisted patterns that look like PII but aren't (noreply addresses, task IDs, etc.)

PII_ALLOWLIST = [ re.compile(r'noreply@anthropic.com'), re.compile(r'noreply@github.com'), re.compile(r'noreply@coditect.ai'), re.compile(r'support@coditect.ai'), re.compile(r'[A-Z].\d+.\d+.\d+'), # task IDs like A.9.1.1 ]

def get_repo_for_project(project_id: Optional[str] = None, use_https: bool = False) -> str: """ Get the git repo URL for a specific project (ADR-159).

Checks config/session-log-repos.json for project-specific repos.
Falls back to default repo.

Args:
project_id: Project to get repo for (None = default)
use_https: Use HTTPS URL instead of SSH

Returns:
Git repo URL string
"""
if HAS_SCOPE and project_id:
routing = get_routing_config("session-log-repos.json")
if routing:
key = "projects_https" if use_https else "projects"
default_key = "default_https" if use_https else "default"
projects = routing.get(key, {})
if project_id in projects:
return projects[project_id]
return routing.get(default_key, GITHUB_REPO_HTTPS if use_https else GITHUB_REPO)
return GITHUB_REPO_HTTPS if use_https else GITHUB_REPO

def is_customer_project(project_id: str) -> bool: """Check if a project ID represents a customer project (CUST-* prefix).""" return project_id.startswith("CUST-")

def get_tenant_from_project(project_id: str) -> Optional[str]: """ Extract tenant ID from a customer project ID. CUST-avivatec-fpa -> 'avivatec' """ if is_customer_project(project_id): parts = project_id.split("-") if len(parts) >= 2: return parts[1] return None

def projects_for_tenant(tenant: str, projects_dir: Path) -> List[str]: """ Find all project IDs matching a tenant (J.27.3.2).

A tenant 'avivatec' matches all CUST-avivatec-* projects.

Args:
tenant: Tenant ID (e.g., 'avivatec')
projects_dir: Path to session-logs/projects/

Returns:
List of matching project IDs
"""
matches = []
if not projects_dir.exists():
return matches
prefix = f"CUST-{tenant}-"
for entry in sorted(projects_dir.iterdir()):
if entry.is_dir() and entry.name.startswith(prefix):
matches.append(entry.name)
return matches

def enforce_customer_permissions(project_dir: Path, project_id: str) -> None: """ Enforce directory permissions for customer projects (J.27.3.1).

CUST-* project directories get 0o700 (owner-only) to prevent
accidental cross-tenant data access in shared environments.

Args:
project_dir: Path to the project directory
project_id: Project ID to check
"""
if not is_customer_project(project_id):
return

if not project_dir.exists():
return

current_mode = project_dir.stat().st_mode & 0o777
if current_mode != 0o700:
project_dir.chmod(0o700)
print(f" [SECURITY] Set {project_id}/ permissions to 0o700 (was 0o{current_mode:03o})")

def check_pii_in_file(file_path: Path) -> List[Tuple[int, str, str]]: """ Scan a file for potential PII patterns (J.27.3.4).

Returns list of (line_number, pattern_type, matched_text) for any PII found
that is NOT in the allowlist.

Args:
file_path: Path to the file to scan

Returns:
List of PII findings as (line, type, match) tuples
"""
findings = []
try:
content = file_path.read_text(encoding='utf-8', errors='replace')
except (IOError, OSError):
return findings

pattern_names = ['email', 'ssn', 'phone', 'credit_card']

for line_no, line in enumerate(content.splitlines(), 1):
for pattern, name in zip(PII_PATTERNS, pattern_names):
for match in pattern.finditer(line):
matched_text = match.group()
# Check allowlist
is_allowed = any(
allow.search(matched_text) for allow in PII_ALLOWLIST
)
if not is_allowed:
findings.append((line_no, name, matched_text))

return findings

def check_pii_redaction(logs: List[Tuple[Path, str]], verbose: bool = False) -> List[Tuple[Path, List]]: """ Check all customer project log files for PII before sync (J.27.3.4).

Only scans CUST-* project logs. Internal projects (PILOT, etc.) are not scanned.

Args:
logs: List of (source_path, relative_dest) tuples
verbose: Print scan progress

Returns:
List of (file_path, findings) tuples for files with PII
"""
pii_files = []
scanned = 0

for log_file, relative_dest in logs:
# Only scan customer project files
parts = relative_dest.split("/")
if len(parts) >= 2 and is_customer_project(parts[1]):
scanned += 1
findings = check_pii_in_file(log_file)
if findings:
pii_files.append((log_file, findings))
if verbose:
print(f" [PII] {relative_dest}: {len(findings)} potential PII matches")

if scanned > 0 and verbose:
print(f" PII scan: {scanned} customer files scanned, {len(pii_files)} with findings")

return pii_files

USER_DATA_DIR = Path.home() / "PROJECTS" / ".coditect-data" CODITECT_DIR = Path.home() / ".coditect" # Framework install (for fallback) SESSION_LOGS_DIR = USER_DATA_DIR / "session-logs" if USER_DATA_DIR.exists() else CODITECT_DIR / "session-logs" GIT_REPO_DIR = USER_DATA_DIR / "session-logs-git" if USER_DATA_DIR.exists() else CODITECT_DIR / "session-logs-git" MACHINE_ID_FILE = USER_DATA_DIR / "machine-id.json" if (USER_DATA_DIR / "machine-id.json").exists() else CODITECT_DIR / "machine-id.json" LOG_GLOB = "SESSION-LOG-*.md"

def get_machine_id() -> Dict[str, str]: """Load machine identification from machine-id.json.""" if not MACHINE_ID_FILE.exists(): print(f"ERROR: Machine ID file not found: {MACHINE_ID_FILE}") print("Run CODITECT-CORE-INITIAL-SETUP.py to generate machine ID") sys.exit(1)

with open(MACHINE_ID_FILE) as f:
return json.load(f)

def run_git(args: List[str], cwd: Path = None, check: bool = True) -> subprocess.CompletedProcess: """Run a git command.""" cmd = ["git"] + args result = subprocess.run( cmd, cwd=cwd or GIT_REPO_DIR, capture_output=True, text=True ) if check and result.returncode != 0: print(f"Git command failed: {' '.join(cmd)}") print(f"stderr: {result.stderr}") if check: sys.exit(1) return result

def init_repo(use_https: bool = False, project_id: Optional[str] = None) -> bool: """Initialize the git repository (ADR-159: project-aware).""" print("=" * 60) print("Initializing Session Logs Git Repository") print("=" * 60)

machine_id = get_machine_id()
machine_uuid = machine_id.get("machine_uuid", "unknown")
hostname = machine_id.get("hostname", "unknown")

# Check if already initialized
if GIT_REPO_DIR.exists() and (GIT_REPO_DIR / ".git").exists():
print(f"Repository already exists at: {GIT_REPO_DIR}")
print("Use --sync to sync logs")
return True

# Create directory
GIT_REPO_DIR.mkdir(parents=True, exist_ok=True)

# Try to clone - ADR-159: use project-specific repo if available
repo_url = get_repo_for_project(project_id, use_https=use_https)
print(f"Cloning from: {repo_url}")
if project_id:
print(f" Project: {project_id}")

result = subprocess.run(
["git", "clone", repo_url, str(GIT_REPO_DIR)],
capture_output=True,
text=True
)

if result.returncode != 0:
# Clone failed - initialize fresh repo
print("Clone failed (repo may be empty), initializing fresh...")

# Init
run_git(["init"], cwd=GIT_REPO_DIR)

# Create initial structure
machines_dir = GIT_REPO_DIR / "machines"
machines_dir.mkdir(exist_ok=True)

# Create README
readme_content = f"""# CODITECT Session Logs

Machine-partitioned session log backup repository.

Structure

machines/
├── {{machine-uuid-1}}/
│ ├── SESSION-LOG-2026-01-24.md
│ └── ...
└── {{machine-uuid-2}}/
└── ...

Machines

UUIDHostnameLast Sync
{machine_uuid[:8]}...{hostname}{datetime.now(timezone.utc).strftime('%Y-%m-%d')}

Usage

# Sync logs from this machine
python3 ~/.coditect/scripts/session-log-git-sync.py --sync

# Pull all machine logs
python3 ~/.coditect/scripts/session-log-git-sync.py --pull

Generated by CODITECT Session Log Git Sync """ (GIT_REPO_DIR / "README.md").write_text(readme_content)

    # Create .gitignore
gitignore_content = """# OS files

.DS_Store Thumbs.db

Editor files

*.swp *~

Temp files

*.tmp """ (GIT_REPO_DIR / ".gitignore").write_text(gitignore_content)

    # Initial commit
run_git(["add", "."])
run_git(["commit", "-m", "Initial commit: repository structure"])

# Add remote
run_git(["remote", "add", "origin", repo_url])

# Set branch name
run_git(["branch", "-M", "main"])

# Push
print("Pushing initial commit...")
result = run_git(["push", "-u", "origin", "main"], check=False)
if result.returncode != 0:
print(f"Warning: Could not push to remote: {result.stderr}")
print("You may need to push manually later")
else:
print("Repository cloned successfully")

# Create machine directory
machine_dir = GIT_REPO_DIR / "machines" / machine_uuid
machine_dir.mkdir(parents=True, exist_ok=True)

# Create machine manifest
manifest = {
"machine_uuid": machine_uuid,
"hostname": hostname,
"created": datetime.now(timezone.utc).isoformat(),
"last_sync": None
}
(machine_dir / "MACHINE-MANIFEST.json").write_text(
json.dumps(manifest, indent=2)
)

print(f"\n✅ Repository initialized at: {GIT_REPO_DIR}")
print(f" Machine directory: machines/{machine_uuid}/")
return True

def discover_all_logs( project_filter: Optional[str] = None, tenant_filter: Optional[str] = None, ) -> List[Tuple[Path, str]]: """ Discover project-scoped session logs (ADR-155, J.27.3).

SSOT: All logs live under projects/{project_id}/{machine_uuid}/.
Legacy flat logs at the session-logs root are symlinks to SSOT and are
NOT synced separately (no duplication).

Returns list of (source_path, relative_dest) tuples where relative_dest
preserves the project structure in the git repo.

Args:
project_filter: If set, only sync logs for this project ID
tenant_filter: If set, only sync logs for CUST-{tenant}-* projects (J.27.3.2)
"""
results = []

if not SESSION_LOGS_DIR.exists():
return results

# Project-scoped logs only: projects/{project_id}/{machine_uuid}/SESSION-LOG-*.md
projects_dir = SESSION_LOGS_DIR / "projects"
if projects_dir.exists():
# J.27.3.2: Resolve tenant filter to matching project IDs
tenant_projects: Optional[Set[str]] = None
if tenant_filter:
tenant_projects = set(projects_for_tenant(tenant_filter, projects_dir))
if not tenant_projects:
print(f" Warning: No projects found for tenant '{tenant_filter}'")

for project_dir in sorted(projects_dir.iterdir()):
if not project_dir.is_dir():
continue
project_id = project_dir.name

# Apply project filter if specified
if project_filter and project_id != project_filter:
continue

# J.27.3.2: Apply tenant filter if specified
if tenant_projects is not None and project_id not in tenant_projects:
continue

# J.27.3.1: Enforce customer directory permissions
enforce_customer_permissions(project_dir, project_id)

# Scan machine UUID subdirectories within project
for machine_dir in sorted(project_dir.iterdir()):
if not machine_dir.is_dir():
continue
# Skip .git directories (embedded repos to be cleaned up)
if machine_dir.name == ".git":
continue

for log_file in sorted(machine_dir.glob(LOG_GLOB)):
relative_dest = f"projects/{project_id}/{log_file.name}"
results.append((log_file, relative_dest))

# Also check for logs directly in project dir (no machine subdir)
for log_file in sorted(project_dir.glob(LOG_GLOB)):
if log_file.parent == project_dir:
relative_dest = f"projects/{project_id}/{log_file.name}"
results.append((log_file, relative_dest))

return results

def sync_logs(dry_run: bool = False, force: bool = False, project_filter: Optional[str] = None, tenant_filter: Optional[str] = None, use_https: bool = False, skip_pii_check: bool = False) -> Tuple[int, int]: """Sync local session logs to git repository (ADR-155/159, J.27.3 multi-tenant).""" print("=" * 60) print("Syncing Session Logs to Git") print("=" * 60)

# ADR-159: Show which repo is being used for this project
if project_filter:
target_repo = get_repo_for_project(project_filter, use_https=use_https)
print(f"Target repo: {target_repo}")
if target_repo != (GITHUB_REPO_HTTPS if use_https else GITHUB_REPO):
print(f" (project-specific routing for {project_filter})")

# J.27.3.2: Show tenant filter
if tenant_filter:
print(f"Tenant filter: {tenant_filter} (matching CUST-{tenant_filter}-* projects)")

if not GIT_REPO_DIR.exists():
print("ERROR: Git repo not initialized. Run with --init first")
sys.exit(1)

machine_id = get_machine_id()
machine_uuid = machine_id.get("machine_uuid", "unknown")

machine_dir = GIT_REPO_DIR / "machines" / machine_uuid
machine_dir.mkdir(parents=True, exist_ok=True)

# Pull latest first
print("Pulling latest changes...")
result = run_git(["pull", "--rebase"], check=False)
if result.returncode != 0 and "no tracking information" not in result.stderr:
print(f"Warning: Pull failed: {result.stderr}")

# Find all project-scoped session logs
if not SESSION_LOGS_DIR.exists():
print(f"ERROR: Session logs directory not found: {SESSION_LOGS_DIR}")
sys.exit(1)

all_logs = discover_all_logs(
project_filter=project_filter,
tenant_filter=tenant_filter,
)
if tenant_filter:
print(f"Found {len(all_logs)} session logs for tenant: {tenant_filter}")
elif project_filter:
print(f"Found {len(all_logs)} session logs for project: {project_filter}")
else:
print(f"Found {len(all_logs)} session logs in {SESSION_LOGS_DIR}")

# J.27.3.4: PII redaction check for customer project logs
if not skip_pii_check:
pii_files = check_pii_redaction(all_logs, verbose=True)
if pii_files:
print(f"\n⚠️ PII WARNING: {len(pii_files)} customer file(s) contain potential PII:")
for pii_path, findings in pii_files:
print(f" {pii_path.name}:")
for line_no, ptype, matched in findings[:3]: # show first 3
redacted = matched[:3] + "***"
print(f" Line {line_no}: {ptype} ({redacted})")
if len(findings) > 3:
print(f" ... and {len(findings) - 3} more")
print()
print(" These files will be EXCLUDED from sync to prevent PII leakage.")
print(" Use --skip-pii-check to override (NOT recommended for customer data).")
# Remove PII files from sync list
pii_paths = {p for p, _ in pii_files}
all_logs = [(f, d) for f, d in all_logs if f not in pii_paths]
print(f" Continuing with {len(all_logs)} clean files.\n")

copied = 0
skipped = 0

for log_file, relative_dest in all_logs:
dest_file = machine_dir / relative_dest
dest_file.parent.mkdir(parents=True, exist_ok=True)

# J.27.3.1: Enforce permissions on customer directories in git repo
parts = relative_dest.split("/")
if len(parts) >= 2 and is_customer_project(parts[1]):
enforce_customer_permissions(dest_file.parent, parts[1])

# Check if needs update
needs_copy = False
if not dest_file.exists():
needs_copy = True
reason = "new"
elif force:
needs_copy = True
reason = "forced"
elif log_file.stat().st_mtime > dest_file.stat().st_mtime:
needs_copy = True
reason = "updated"
else:
skipped += 1
continue

if dry_run:
print(f" [DRY-RUN] Would copy: {relative_dest} ({reason})")
copied += 1
else:
shutil.copy2(log_file, dest_file)
print(f" Copied: {relative_dest} ({reason})")
copied += 1

if dry_run:
print(f"\n[DRY-RUN] Would copy {copied} files, skip {skipped}")
return copied, skipped

print(f"\nCopied {copied} files, skipped {skipped} (unchanged)")

if copied == 0:
print("No changes to commit")
return copied, skipped

# Update manifest with project list
manifest_file = machine_dir / "MACHINE-MANIFEST.json"
if manifest_file.exists():
manifest = json.loads(manifest_file.read_text())
else:
manifest = {
"machine_uuid": machine_uuid,
"hostname": machine_id.get("hostname", "unknown"),
"created": datetime.now(timezone.utc).isoformat()
}
manifest["last_sync"] = datetime.now(timezone.utc).isoformat()

# Count logs per project
project_dirs = list((machine_dir / "projects").iterdir()) if (machine_dir / "projects").exists() else []
projects = {}
for pd in project_dirs:
if pd.is_dir():
projects[pd.name] = len(list(pd.glob(LOG_GLOB)))
manifest["log_count"] = sum(projects.values())
manifest["projects"] = projects
manifest_file.write_text(json.dumps(manifest, indent=2))

# Git add and commit
run_git(["add", "."])

# Check if there are changes to commit
result = run_git(["status", "--porcelain"], check=False)
if not result.stdout.strip():
print("No changes to commit")
return copied, skipped

# Create commit message
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
hostname = machine_id.get("hostname", "unknown")
scope_note = ""
if tenant_filter:
scope_note = f" (tenant: {tenant_filter})"
elif project_filter:
scope_note = f" ({project_filter})"
commit_msg = f"""chore(logs): sync {copied} session logs from {hostname}{scope_note}

Machine: {machine_uuid} Timestamp: {timestamp} Files synced: {copied} Projects: {', '.join(projects.keys()) if projects else 'legacy only'}

Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com """

run_git(["commit", "-m", commit_msg])
print(f"Committed: {copied} files")

# Push
print("Pushing to remote...")
result = run_git(["push"], check=False)
if result.returncode != 0:
print(f"Warning: Push failed: {result.stderr}")
print("Changes committed locally. Push manually with: git push")
else:
print("✅ Pushed to remote successfully")

return copied, skipped

def pull_logs() -> int: """Pull latest logs from all machines.""" print("=" * 60) print("Pulling Session Logs from Remote") print("=" * 60)

if not GIT_REPO_DIR.exists():
print("ERROR: Git repo not initialized. Run with --init first")
sys.exit(1)

result = run_git(["pull", "--rebase"], check=False)
if result.returncode != 0:
print(f"Pull failed: {result.stderr}")
return 0

# Count logs per machine
machines_dir = GIT_REPO_DIR / "machines"
if not machines_dir.exists():
print("No machines directory found")
return 0

total_logs = 0
for machine_dir in machines_dir.iterdir():
if not machine_dir.is_dir():
continue

# Count project-scoped logs
project_logs = []
projects_dir = machine_dir / "projects"
project_names = []
if projects_dir.exists():
for pd in sorted(projects_dir.iterdir()):
if pd.is_dir():
plogs = list(pd.glob(LOG_GLOB))
project_logs.extend(plogs)
if plogs:
project_names.append(f"{pd.name}({len(plogs)})")
all_logs = len(project_logs)
total_logs += all_logs

# Read manifest
manifest_file = machine_dir / "MACHINE-MANIFEST.json"
if manifest_file.exists():
manifest = json.loads(manifest_file.read_text())
hostname = manifest.get("hostname", "unknown")
last_sync = manifest.get("last_sync", "never")
else:
hostname = "unknown"
last_sync = "never"

projects_info = f", projects: {', '.join(project_names)}" if project_names else ""
print(f" {machine_dir.name[:12]}... ({hostname}): {all_logs} logs{projects_info}, last sync: {last_sync[:10] if last_sync != 'never' else 'never'}")

print(f"\n✅ Pulled {total_logs} total logs from remote")
return total_logs

def show_status() -> None: """Show sync status (ADR-155 project-scoped aware).""" print("=" * 60) print("Session Log Git Sync Status") print("=" * 60)

machine_id = get_machine_id()
machine_uuid = machine_id.get("machine_uuid", "unknown")
hostname = machine_id.get("hostname", "unknown")

print(f"\nMachine: {hostname}")
print(f"UUID: {machine_uuid}")

# Local logs - project-scoped only
if SESSION_LOGS_DIR.exists():
all_local = discover_all_logs()
project_counts: Dict[str, int] = {}
for _, dest in all_local:
proj = dest.split("/")[1]
project_counts[proj] = project_counts.get(proj, 0) + 1

print(f"\nLocal Logs: {len(all_local)} files")
for proj, count in sorted(project_counts.items()):
print(f" {proj}: {count}")
print(f"SSOT: {SESSION_LOGS_DIR}/projects/")
else:
print(f"\nLocal Logs: Directory not found")
all_local = []

# Git repo
if GIT_REPO_DIR.exists() and (GIT_REPO_DIR / ".git").exists():
print(f"\nGit Repo: {GIT_REPO_DIR}")

# Check remote
result = run_git(["remote", "-v"], check=False)
if result.returncode == 0:
print(f"Remote: {result.stdout.strip().split()[1] if result.stdout else 'none'}")

# Check status
result = run_git(["status", "--porcelain"], check=False)
if result.stdout.strip():
print(f"Status: {len(result.stdout.strip().split(chr(10)))} uncommitted changes")
else:
print("Status: Clean")

# Machine directory - count synced project logs
machine_dir = GIT_REPO_DIR / "machines" / machine_uuid
if machine_dir.exists():
synced_projects = []
if (machine_dir / "projects").exists():
for pd in (machine_dir / "projects").iterdir():
if pd.is_dir():
synced_projects.extend(list(pd.glob(LOG_GLOB)))
print(f"Synced: {len(synced_projects)} logs in git repo")

# Check for unsynced
local_dests = {d for _, d in all_local}
synced_dests = set()
for l in synced_projects:
parts = l.relative_to(machine_dir).parts
synced_dests.add("/".join(parts))
unsynced = local_dests - synced_dests
if unsynced:
print(f"Unsynced: {len(unsynced)} logs need sync")
else:
print("Synced: Machine directory not created yet")
else:
print(f"\nGit Repo: Not initialized")
print("Run: python3 session-log-git-sync.py --init")

def list_machines() -> None: """List all machines with logs (ADR-155 project-scoped aware).""" print("=" * 60) print("Machines with Session Logs") print("=" * 60)

if not GIT_REPO_DIR.exists():
print("Git repo not initialized. Run with --init first")
return

machines_dir = GIT_REPO_DIR / "machines"
if not machines_dir.exists():
print("No machines found")
return

current_uuid = get_machine_id().get("machine_uuid", "")

print(f"\n{'UUID':<40} {'Hostname':<20} {'Logs':<6} {'Projects':<30} {'Last Sync'}")
print("-" * 110)

for machine_dir in sorted(machines_dir.iterdir()):
if not machine_dir.is_dir():
continue

uuid = machine_dir.name

# Count project-scoped logs
project_names = []
total_logs = 0
if (machine_dir / "projects").exists():
for pd in sorted((machine_dir / "projects").iterdir()):
if pd.is_dir():
plogs = list(pd.glob(LOG_GLOB))
total_logs += len(plogs)
if plogs:
project_names.append(pd.name)

manifest_file = machine_dir / "MACHINE-MANIFEST.json"
if manifest_file.exists():
manifest = json.loads(manifest_file.read_text())
hostname = manifest.get("hostname", "unknown")
last_sync = manifest.get("last_sync", "never")
if last_sync and last_sync != "never":
last_sync = last_sync[:19].replace("T", " ")
else:
hostname = "unknown"
last_sync = "never"

projects_str = ", ".join(project_names) if project_names else "-"
marker = " *" if uuid == current_uuid else ""
print(f"{uuid:<40} {hostname:<20} {total_logs:<6} {projects_str:<30} {last_sync}{marker}")

print("\n* = current machine")

def main(): parser = argparse.ArgumentParser( description="Sync session logs to GitHub repository", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # First time setup python3 session-log-git-sync.py --init

# Sync ALL logs (legacy + all projects)
python3 session-log-git-sync.py --sync

# Sync specific project only
python3 session-log-git-sync.py --sync --project PILOT

# Sync all projects for a tenant (J.27.3.2)
python3 session-log-git-sync.py --sync --tenant avivatec

# Preview sync without making changes
python3 session-log-git-sync.py --sync --dry-run

# Pull latest from all machines
python3 session-log-git-sync.py --pull

# Check status
python3 session-log-git-sync.py --status

# List all machines and their projects
python3 session-log-git-sync.py --list-machines

""" )

parser.add_argument("--init", action="store_true",
help="Initialize git repository")
parser.add_argument("--sync", action="store_true",
help="Sync local logs to git and push")
parser.add_argument("--pull", action="store_true",
help="Pull latest from remote")
parser.add_argument("--status", action="store_true",
help="Show sync status")
parser.add_argument("--list-machines", action="store_true",
help="List all machines with logs")
parser.add_argument("--project", type=str, default=None,
help="Filter sync to specific project ID (e.g., PILOT)")
parser.add_argument("--tenant", type=str, default=None,
help="Filter sync to all projects for a tenant "
"(e.g., --tenant avivatec matches CUST-avivatec-*)")
parser.add_argument("--dry-run", action="store_true",
help="Preview changes without executing")
parser.add_argument("--force", action="store_true",
help="Force overwrite even if unchanged")
parser.add_argument("--https", action="store_true",
help="Use HTTPS instead of SSH for git")
parser.add_argument("--skip-pii-check", action="store_true",
help="Skip PII redaction check (NOT recommended for customer data)")

args = parser.parse_args()

# Default to status if no action specified
if not any([args.init, args.sync, args.pull, args.status, args.list_machines]):
args.status = True

if args.init:
init_repo(use_https=args.https, project_id=args.project)
elif args.sync:
sync_logs(dry_run=args.dry_run, force=args.force,
project_filter=args.project, tenant_filter=args.tenant,
use_https=args.https, skip_pii_check=args.skip_pii_check)
elif args.pull:
pull_logs()
elif args.list_machines:
list_machines()
elif args.status:
show_status()

if name == "main": main()