@@ -632,6 +632,218 @@ def get_causal_graph(self, head_hash: str | None = None, depth: int = 50) -> dic
632632
633633 return {"nodes" : nodes , "edges" : edges }
634634
635+ def get_retention_preview (self , policy_dict : dict [str , Any ]) -> dict [str , Any ]:
636+ """Analyze objects/commits and return what would be deleted under policy."""
637+ max_age_secs = policy_dict .get ("max_age_secs" )
638+ max_commits = policy_dict .get ("max_commits" )
639+ keep_branches = policy_dict .get ("keep_branches" , ["main" ])
640+ max_log_age_secs = policy_dict .get ("max_log_age_secs" )
641+ max_log_entries = policy_dict .get ("max_log_entries" )
642+
643+ now = time .time ()
644+ all_commits = self .log (limit = 10000 )
645+
646+ # Determine which commits would be expired
647+ commits_expired = 0
648+ commits_retained = 0
649+ for i , c in enumerate (all_commits ):
650+ expired = False
651+ if max_age_secs is not None :
652+ try :
653+ ts = time .mktime (time .strptime (c .timestamp , "%Y-%m-%dT%H:%M:%SZ" ))
654+ if (now - ts ) > max_age_secs :
655+ expired = True
656+ except (ValueError , OverflowError ):
657+ pass
658+ if max_commits is not None and i >= max_commits :
659+ expired = True
660+ if expired :
661+ commits_expired += 1
662+ else :
663+ commits_retained += 1
664+
665+ objects_before = len (self ._objects )
666+
667+ # Estimate objects that would be deleted (2 per expired commit: tree + commit)
668+ objects_deleted = commits_expired * 2
669+
670+ # Estimate log entries that would be pruned
671+ logs_pruned = 0
672+ with self ._lock :
673+ total_logs = len (self ._audit )
674+ if max_log_entries is not None and total_logs > max_log_entries :
675+ logs_pruned = total_logs - max_log_entries
676+ if max_log_age_secs is not None :
677+ for entry in list (self ._audit ):
678+ try :
679+ ts = time .mktime (time .strptime (entry ["timestamp" ], "%Y-%m-%dT%H:%M:%SZ" ))
680+ if (now - ts ) > max_log_age_secs :
681+ logs_pruned += 1
682+ except (ValueError , OverflowError ):
683+ pass
684+
685+ return {
686+ "commits_expired" : commits_expired ,
687+ "commits_retained" : commits_retained ,
688+ "objects_deleted" : objects_deleted ,
689+ "logs_pruned" : logs_pruned ,
690+ "objects_before" : objects_before ,
691+ "objects_after" : max (0 , objects_before - objects_deleted ),
692+ }
693+
694+ def enforce_retention (self , policy_dict : dict [str , Any ]) -> dict [str , Any ]:
695+ """Actually delete expired objects and prune logs based on policy."""
696+ max_age_secs = policy_dict .get ("max_age_secs" )
697+ max_commits = policy_dict .get ("max_commits" )
698+ keep_branches = policy_dict .get ("keep_branches" , ["main" ])
699+ max_log_age_secs = policy_dict .get ("max_log_age_secs" )
700+ max_log_entries = policy_dict .get ("max_log_entries" )
701+
702+ now = time .time ()
703+ objects_before = len (self ._objects )
704+ all_commits = self .log (limit = 10000 )
705+
706+ # Find protected commit hashes (branch tips for kept branches)
707+ protected_hashes : set [str ] = set ()
708+ with self ._lock :
709+ for br in keep_branches :
710+ if br in self ._branches :
711+ protected_hashes .add (self ._branches [br ])
712+
713+ commits_expired = 0
714+ commits_retained = 0
715+ hashes_to_remove : set [str ] = set ()
716+
717+ for i , c in enumerate (all_commits ):
718+ if c .hash in protected_hashes :
719+ commits_retained += 1
720+ continue
721+ expired = False
722+ if max_age_secs is not None :
723+ try :
724+ ts = time .mktime (time .strptime (c .timestamp , "%Y-%m-%dT%H:%M:%SZ" ))
725+ if (now - ts ) > max_age_secs :
726+ expired = True
727+ except (ValueError , OverflowError ):
728+ pass
729+ if max_commits is not None and i >= max_commits :
730+ expired = True
731+ if expired :
732+ commits_expired += 1
733+ hashes_to_remove .add (c .hash )
734+ if c .tree_hash :
735+ hashes_to_remove .add (c .tree_hash )
736+ else :
737+ commits_retained += 1
738+
739+ # Delete expired objects
740+ objects_deleted = 0
741+ for h in hashes_to_remove :
742+ if h in self ._objects :
743+ with self ._lock :
744+ self ._objects .pop (h , None )
745+ if self ._db_path :
746+ con = sqlite3 .connect (self ._db_path )
747+ con .execute ("DELETE FROM objects WHERE hash=?" , (h ,))
748+ con .commit ()
749+ con .close ()
750+ objects_deleted += 1
751+
752+ # Prune logs
753+ logs_pruned = 0
754+ with self ._lock :
755+ original_logs = list (self ._audit )
756+
757+ kept_logs = list (original_logs )
758+ if max_log_age_secs is not None :
759+ kept_logs_new = []
760+ for entry in kept_logs :
761+ try :
762+ ts = time .mktime (time .strptime (entry ["timestamp" ], "%Y-%m-%dT%H:%M:%SZ" ))
763+ if (now - ts ) <= max_log_age_secs :
764+ kept_logs_new .append (entry )
765+ else :
766+ logs_pruned += 1
767+ except (ValueError , OverflowError ):
768+ kept_logs_new .append (entry )
769+ kept_logs = kept_logs_new
770+
771+ if max_log_entries is not None and len (kept_logs ) > max_log_entries :
772+ excess = len (kept_logs ) - max_log_entries
773+ logs_pruned += excess
774+ kept_logs = kept_logs [- max_log_entries :]
775+
776+ with self ._lock :
777+ self ._audit = kept_logs
778+
779+ objects_after = len (self ._objects )
780+ return {
781+ "commits_expired" : commits_expired ,
782+ "commits_retained" : commits_retained ,
783+ "objects_deleted" : objects_deleted ,
784+ "logs_pruned" : logs_pruned ,
785+ "objects_before" : objects_before ,
786+ "objects_after" : objects_after ,
787+ }
788+
789+ def get_schema_version (self ) -> int :
790+ """Return current schema version from schema_version table."""
791+ if self ._db_path is None :
792+ return 1
793+ con = sqlite3 .connect (self ._db_path )
794+ try :
795+ row = con .execute ("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1" ).fetchone ()
796+ if row :
797+ return int (row [0 ])
798+ return 1
799+ except sqlite3 .OperationalError :
800+ # Table does not exist yet; version 1 is baseline
801+ return 1
802+ finally :
803+ con .close ()
804+
805+ def apply_migrations (self ) -> dict [str , Any ]:
806+ """Apply pending schema migrations. Returns migration result."""
807+ current_version = self .get_schema_version ()
808+
809+ if self ._db_path is None :
810+ return {"from_version" : current_version , "to_version" : current_version , "migrations_applied" : 0 }
811+
812+ con = sqlite3 .connect (self ._db_path )
813+ try :
814+ # Ensure schema_version table exists
815+ con .execute (
816+ "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT)"
817+ )
818+ con .commit ()
819+
820+ # Define available migrations (version -> DDL)
821+ migrations : list [tuple [int , str ]] = [
822+ (2 , "CREATE TABLE IF NOT EXISTS branches (name TEXT PRIMARY KEY, commit_hash TEXT, created_at TEXT)" ),
823+ (3 , "CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit (ts)" ),
824+ ]
825+
826+ applied = 0
827+ target_version = current_version
828+ for version , ddl in migrations :
829+ if version > current_version :
830+ con .execute (ddl )
831+ con .execute (
832+ "INSERT OR REPLACE INTO schema_version VALUES (?, ?)" ,
833+ (version , time .strftime ("%Y-%m-%dT%H:%M:%SZ" , time .gmtime ())),
834+ )
835+ con .commit ()
836+ target_version = version
837+ applied += 1
838+
839+ return {
840+ "from_version" : current_version ,
841+ "to_version" : target_version ,
842+ "migrations_applied" : applied ,
843+ }
844+ finally :
845+ con .close ()
846+
635847 # --- Internal ---
636848
637849 def _append_audit (self , action : str , message : str , commit_hash : str | None ) -> None :
0 commit comments