contree-cli 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
contree_cli/session.py ADDED
@@ -0,0 +1,761 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import gzip
5
+ import json
6
+ import os
7
+ import posixpath
8
+ import sqlite3
9
+ import sys
10
+ import uuid
11
+ from collections.abc import Iterator, MutableMapping
12
+ from dataclasses import dataclass
13
+ from functools import cached_property
14
+ from pathlib import Path, PurePosixPath
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Session:
19
+ session_key: str
20
+ active_branch: str
21
+ current_image: str
22
+ last_kind: str
23
+ last_title: str
24
+ updated_at: str
25
+ cwd: str = ""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PendingFile:
30
+ instance_path: str
31
+ file_uuid: str
32
+ uid: int
33
+ gid: int
34
+ mode: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class HistoryEntry:
39
+ id: int
40
+ image_uuid: str
41
+ parent_id: int | None
42
+ kind: str
43
+ title: str
44
+ created_at: str
45
+
46
+
47
+ SCHEMA = """
48
+ CREATE TABLE IF NOT EXISTS session_history (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ session_key TEXT NOT NULL,
51
+ image_uuid TEXT NOT NULL,
52
+ parent_id INTEGER REFERENCES session_history(id),
53
+ kind TEXT NOT NULL DEFAULT '',
54
+ title TEXT NOT NULL DEFAULT '',
55
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS session_branches (
59
+ session_key TEXT NOT NULL,
60
+ branch_name TEXT NOT NULL,
61
+ history_id INTEGER NOT NULL REFERENCES session_history(id),
62
+ PRIMARY KEY (session_key, branch_name)
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS session_state (
66
+ session_key TEXT PRIMARY KEY,
67
+ active_branch TEXT NOT NULL DEFAULT 'main',
68
+ cwd TEXT NOT NULL DEFAULT '',
69
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS session_files (
73
+ history_id INTEGER NOT NULL REFERENCES session_history(id),
74
+ instance_path TEXT NOT NULL,
75
+ file_uuid TEXT NOT NULL,
76
+ uid INTEGER NOT NULL DEFAULT 0,
77
+ gid INTEGER NOT NULL DEFAULT 0,
78
+ mode TEXT NOT NULL DEFAULT '0644',
79
+ PRIMARY KEY (history_id, instance_path)
80
+ );
81
+
82
+ CREATE TABLE IF NOT EXISTS shell_history (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ session_key TEXT NOT NULL,
85
+ line TEXT NOT NULL,
86
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS image_cache (
90
+ image_uuid TEXT NOT NULL,
91
+ kind TEXT NOT NULL,
92
+ value TEXT NOT NULL,
93
+ PRIMARY KEY (image_uuid, kind)
94
+ );
95
+
96
+ CREATE INDEX IF NOT EXISTS ix_history_session ON session_history(session_key);
97
+ CREATE INDEX IF NOT EXISTS ix_history_parent ON session_history(parent_id);
98
+ CREATE INDEX IF NOT EXISTS ix_shell_history_session ON shell_history(session_key);
99
+ CREATE INDEX IF NOT EXISTS ix_image_cache_kind ON image_cache(kind);
100
+ """
101
+
102
+
103
+ def _entry_from_row(row: sqlite3.Row) -> HistoryEntry:
104
+ return HistoryEntry(
105
+ id=row["id"],
106
+ image_uuid=row["image_uuid"],
107
+ parent_id=row["parent_id"],
108
+ kind=row["kind"],
109
+ title=row["title"],
110
+ created_at=row["created_at"],
111
+ )
112
+
113
+
114
+ CacheKey = tuple[str, str]
115
+
116
+ _GZIP_THRESHOLD = 1024
117
+
118
+
119
+ class ImageCache(MutableMapping[CacheKey, object]):
120
+ """Persistent cache for immutable container image data.
121
+
122
+ Key is ``(image_uuid, kind)`` where *kind* encodes the cache
123
+ entry type (e.g. ``"files:/etc/"`` or ``"images"``). Values are
124
+ transparently JSON-serialised. Payloads above 1 KiB are
125
+ gzip-compressed and base64-encoded (``gzip:...`` prefix);
126
+ smaller payloads are stored as plain JSON (``json:...`` prefix).
127
+ """
128
+
129
+ __slots__ = ("_conn",)
130
+
131
+ def __init__(self, conn: sqlite3.Connection) -> None:
132
+ self._conn = conn
133
+
134
+ @staticmethod
135
+ def _encode(value: object) -> str:
136
+ raw = json.dumps(value)
137
+ if len(raw) <= _GZIP_THRESHOLD:
138
+ return f"json:{raw}"
139
+ compressed = base64.b64encode(
140
+ gzip.compress(raw.encode()),
141
+ ).decode("ascii")
142
+ return f"gzip:{compressed}"
143
+
144
+ @staticmethod
145
+ def _decode(blob: str) -> object:
146
+ if blob.startswith("json:"):
147
+ return json.loads(blob[5:])
148
+ if blob.startswith("gzip:"):
149
+ return json.loads(
150
+ gzip.decompress(base64.b64decode(blob[5:])),
151
+ )
152
+ # Legacy: no prefix — plain JSON (written before this change).
153
+ return json.loads(blob)
154
+
155
+ def __getitem__(self, key: CacheKey) -> object:
156
+ image_uuid, kind = key
157
+ row = self._conn.execute(
158
+ "SELECT value FROM image_cache "
159
+ "WHERE image_uuid=? AND kind=?",
160
+ (image_uuid, kind),
161
+ ).fetchone()
162
+ if row is None:
163
+ raise KeyError(key)
164
+ return self._decode(row["value"])
165
+
166
+ def __setitem__(self, key: CacheKey, value: object) -> None:
167
+ image_uuid, kind = key
168
+ self._conn.execute(
169
+ "INSERT OR REPLACE INTO image_cache "
170
+ "(image_uuid, kind, value) VALUES (?, ?, ?)",
171
+ (image_uuid, kind, self._encode(value)),
172
+ )
173
+ self._conn.commit()
174
+
175
+ def __contains__(self, key: object) -> bool:
176
+ if not isinstance(key, tuple) or len(key) != 2:
177
+ return False
178
+ image_uuid, kind = key
179
+ row = self._conn.execute(
180
+ "SELECT 1 FROM image_cache "
181
+ "WHERE image_uuid=? AND kind=?",
182
+ (image_uuid, kind),
183
+ ).fetchone()
184
+ return row is not None
185
+
186
+ def __delitem__(self, key: CacheKey) -> None:
187
+ image_uuid, kind = key
188
+ cur = self._conn.execute(
189
+ "DELETE FROM image_cache "
190
+ "WHERE image_uuid=? AND kind=?",
191
+ (image_uuid, kind),
192
+ )
193
+ if cur.rowcount == 0:
194
+ raise KeyError(key)
195
+ self._conn.commit()
196
+
197
+ def __iter__(self) -> Iterator[CacheKey]:
198
+ rows = self._conn.execute(
199
+ "SELECT image_uuid, kind FROM image_cache",
200
+ ).fetchall()
201
+ return iter(
202
+ (row["image_uuid"], row["kind"]) for row in rows
203
+ )
204
+
205
+ def __len__(self) -> int:
206
+ row = self._conn.execute(
207
+ "SELECT COUNT(*) FROM image_cache",
208
+ ).fetchone()
209
+ assert row is not None
210
+ return row[0] # type: ignore[no-any-return]
211
+
212
+
213
+ class SessionStore:
214
+ MAX_SHELL_HISTORY = 10_000
215
+
216
+ def __init__(self, db_path: Path, session_key: str) -> None:
217
+ self._session_key = session_key
218
+ db_path.parent.mkdir(parents=True, exist_ok=True)
219
+ self._conn = sqlite3.connect(str(db_path), timeout=5.0)
220
+ self._conn.row_factory = sqlite3.Row
221
+ self._conn.execute("PRAGMA journal_mode=WAL")
222
+ self._conn.execute("PRAGMA busy_timeout=5000")
223
+ self._conn.executescript(SCHEMA)
224
+ self._migrate()
225
+
226
+ def _migrate(self) -> None:
227
+ """Apply schema migrations for existing databases."""
228
+ try:
229
+ self._conn.execute(
230
+ "ALTER TABLE session_state "
231
+ "ADD COLUMN cwd TEXT NOT NULL DEFAULT ''"
232
+ )
233
+ self._conn.commit()
234
+ except sqlite3.OperationalError:
235
+ pass # Column already exists
236
+
237
+ @cached_property
238
+ def cache(self) -> ImageCache:
239
+ return ImageCache(self._conn)
240
+
241
+ def _query_sessions(
242
+ self, suffix: str = "", params: tuple[object, ...] = (),
243
+ ) -> list[Session]:
244
+ cur = self._conn.execute(
245
+ """
246
+ SELECT s.session_key, s.active_branch, s.cwd,
247
+ h.image_uuid, h.kind, h.title, s.updated_at
248
+ FROM session_state s
249
+ JOIN session_branches b
250
+ ON b.session_key = s.session_key
251
+ AND b.branch_name = s.active_branch
252
+ JOIN session_history h ON h.id = b.history_id
253
+ """
254
+ + suffix,
255
+ params,
256
+ )
257
+ return [
258
+ Session(
259
+ session_key=row["session_key"],
260
+ active_branch=row["active_branch"],
261
+ current_image=row["image_uuid"],
262
+ last_kind=row["kind"],
263
+ last_title=row["title"],
264
+ updated_at=row["updated_at"],
265
+ cwd=row["cwd"],
266
+ )
267
+ for row in cur.fetchall()
268
+ ]
269
+
270
+ @property
271
+ def session_key(self) -> str:
272
+ return self._session_key
273
+
274
+ @property
275
+ def current_image(self) -> str:
276
+ s = self.session
277
+ if s is None:
278
+ print(
279
+ "No active session. Run `contree use IMAGE` first.",
280
+ file=sys.stderr,
281
+ )
282
+ raise SystemExit(1)
283
+ return s.current_image
284
+
285
+ @property
286
+ def session(self) -> Session | None:
287
+ rows = self._query_sessions(
288
+ "WHERE s.session_key = ?", (self._session_key,),
289
+ )
290
+ return rows[0] if rows else None
291
+
292
+ def history_depth(self) -> int:
293
+ """Count steps from root to the current branch tip."""
294
+ cur = self._conn.execute(
295
+ """
296
+ SELECT b.history_id
297
+ FROM session_state s
298
+ JOIN session_branches b
299
+ ON b.session_key = s.session_key
300
+ AND b.branch_name = s.active_branch
301
+ WHERE s.session_key = ?
302
+ """,
303
+ (self._session_key,),
304
+ )
305
+ row = cur.fetchone()
306
+ if row is None:
307
+ return 0
308
+ depth = 0
309
+ hid: int | None = row["history_id"]
310
+ while hid is not None:
311
+ depth += 1
312
+ cur = self._conn.execute(
313
+ "SELECT parent_id FROM session_history WHERE id = ?",
314
+ (hid,),
315
+ )
316
+ parent = cur.fetchone()
317
+ hid = parent["parent_id"] if parent is not None else None
318
+ return depth
319
+
320
+ def _get_history_entry(self, history_id: int) -> HistoryEntry:
321
+ cur = self._conn.execute(
322
+ "SELECT * FROM session_history WHERE id = ?",
323
+ (history_id,),
324
+ )
325
+ row = cur.fetchone()
326
+ if row is None:
327
+ raise ValueError(f"History entry {history_id} not found")
328
+ return _entry_from_row(row)
329
+
330
+ def rollback(self, n: int = 1) -> HistoryEntry:
331
+ if n < 1:
332
+ raise ValueError("Rollback steps must be >= 1")
333
+ # Get active branch tip
334
+ cur = self._conn.execute(
335
+ """
336
+ SELECT b.history_id, s.active_branch
337
+ FROM session_state s
338
+ JOIN session_branches b
339
+ ON b.session_key = s.session_key
340
+ AND b.branch_name = s.active_branch
341
+ WHERE s.session_key = ?
342
+ """,
343
+ (self._session_key,),
344
+ )
345
+ row = cur.fetchone()
346
+ if row is None:
347
+ raise ValueError("No active session")
348
+ history_id: int = row["history_id"]
349
+ branch: str = row["active_branch"]
350
+
351
+ # Walk parent chain N steps
352
+ current_id = history_id
353
+ for i in range(n):
354
+ entry = self._get_history_entry(current_id)
355
+ if entry.parent_id is None:
356
+ raise ValueError(
357
+ f"Cannot rollback {n} steps:"
358
+ f" only {i} ancestors available"
359
+ )
360
+ current_id = entry.parent_id
361
+
362
+ # Move branch pointer
363
+ self._conn.execute(
364
+ "UPDATE session_branches SET history_id = ? "
365
+ "WHERE session_key = ? AND branch_name = ?",
366
+ (current_id, self._session_key, branch),
367
+ )
368
+ self._conn.execute(
369
+ "UPDATE session_state "
370
+ "SET updated_at = strftime('%Y-%m-%dT%H:%M:%S','now') "
371
+ "WHERE session_key = ?",
372
+ (self._session_key,),
373
+ )
374
+ self._conn.commit()
375
+ return self._get_history_entry(current_id)
376
+
377
+ def create_branch(
378
+ self, name: str, from_branch: str | None = None,
379
+ ) -> None:
380
+ source = from_branch or self._active_branch()
381
+ if source is None:
382
+ raise ValueError("No active session")
383
+
384
+ # Check source branch exists
385
+ cur = self._conn.execute(
386
+ "SELECT history_id FROM session_branches "
387
+ "WHERE session_key = ? AND branch_name = ?",
388
+ (self._session_key, source),
389
+ )
390
+ row = cur.fetchone()
391
+ if row is None:
392
+ raise ValueError(
393
+ f"Source branch {source!r} does not exist",
394
+ )
395
+ history_id: int = row["history_id"]
396
+
397
+ # Check name doesn't already exist
398
+ cur = self._conn.execute(
399
+ "SELECT 1 FROM session_branches "
400
+ "WHERE session_key = ? AND branch_name = ?",
401
+ (self._session_key, name),
402
+ )
403
+ if cur.fetchone() is not None:
404
+ raise ValueError(f"Branch {name!r} already exists")
405
+
406
+ self._conn.execute(
407
+ "INSERT INTO session_branches "
408
+ "(session_key, branch_name, history_id) "
409
+ "VALUES (?, ?, ?)",
410
+ (self._session_key, name, history_id),
411
+ )
412
+ self._conn.commit()
413
+
414
+ def list_branches(self) -> list[tuple[str, bool]]:
415
+ active = self._active_branch()
416
+ if active is None:
417
+ return []
418
+ cur = self._conn.execute(
419
+ "SELECT branch_name FROM session_branches "
420
+ "WHERE session_key = ? ORDER BY branch_name",
421
+ (self._session_key,),
422
+ )
423
+ return [
424
+ (row["branch_name"], row["branch_name"] == active)
425
+ for row in cur.fetchall()
426
+ ]
427
+
428
+ def switch_branch(self, name: str) -> HistoryEntry:
429
+ cur = self._conn.execute(
430
+ "SELECT history_id FROM session_branches "
431
+ "WHERE session_key = ? AND branch_name = ?",
432
+ (self._session_key, name),
433
+ )
434
+ row = cur.fetchone()
435
+ if row is None:
436
+ raise ValueError(f"Branch {name!r} does not exist")
437
+ history_id: int = row["history_id"]
438
+
439
+ self._conn.execute(
440
+ "UPDATE session_state SET active_branch = ?, "
441
+ "updated_at = strftime('%Y-%m-%dT%H:%M:%S','now') "
442
+ "WHERE session_key = ?",
443
+ (name, self._session_key),
444
+ )
445
+ self._conn.commit()
446
+ return self._get_history_entry(history_id)
447
+
448
+ def list_sessions(self) -> list[Session]:
449
+ return self._query_sessions("ORDER BY s.updated_at DESC")
450
+
451
+ def history_dag(
452
+ self,
453
+ ) -> tuple[list[HistoryEntry], dict[int, list[str]]]:
454
+ cur = self._conn.execute(
455
+ "SELECT * FROM session_history "
456
+ "WHERE session_key = ? ORDER BY id",
457
+ (self._session_key,),
458
+ )
459
+ entries = [_entry_from_row(row) for row in cur.fetchall()]
460
+
461
+ cur = self._conn.execute(
462
+ "SELECT history_id, branch_name "
463
+ "FROM session_branches WHERE session_key = ?",
464
+ (self._session_key,),
465
+ )
466
+ branch_map: dict[int, list[str]] = {}
467
+ for row in cur.fetchall():
468
+ branch_map.setdefault(row["history_id"], []).append(
469
+ row["branch_name"],
470
+ )
471
+ return entries, branch_map
472
+
473
+ def find_session(self, name: str) -> Session:
474
+ # Try suffix match first
475
+ rows = self._query_sessions(
476
+ "WHERE s.session_key LIKE ?", (f"%_{name}",),
477
+ )
478
+ if not rows:
479
+ # Try exact match
480
+ rows = self._query_sessions(
481
+ "WHERE s.session_key = ?", (name,),
482
+ )
483
+ if not rows:
484
+ raise ValueError(f"Session {name!r} not found")
485
+ if len(rows) > 1:
486
+ keys = ", ".join(r.session_key for r in rows)
487
+ raise ValueError(
488
+ f"Ambiguous session {name!r}: matches {keys}",
489
+ )
490
+ return rows[0]
491
+
492
+ def _active_branch(self) -> str | None:
493
+ cur = self._conn.execute(
494
+ "SELECT active_branch FROM session_state "
495
+ "WHERE session_key = ?",
496
+ (self._session_key,),
497
+ )
498
+ row = cur.fetchone()
499
+ return row["active_branch"] if row else None
500
+
501
+ def set_image(
502
+ self, image_uuid: str, *, kind: str = "", title: str = "",
503
+ ) -> int:
504
+ # Look up active branch's current history_id
505
+ cur = self._conn.execute(
506
+ """
507
+ SELECT b.history_id, s.active_branch
508
+ FROM session_state s
509
+ JOIN session_branches b
510
+ ON b.session_key = s.session_key
511
+ AND b.branch_name = s.active_branch
512
+ WHERE s.session_key = ?
513
+ """,
514
+ (self._session_key,),
515
+ )
516
+ row = cur.fetchone()
517
+ parent_id: int | None = row["history_id"] if row else None
518
+ branch: str = row["active_branch"] if row else "main"
519
+
520
+ # Insert history entry
521
+ cur = self._conn.execute(
522
+ """
523
+ INSERT INTO session_history
524
+ (session_key, image_uuid, parent_id, kind, title)
525
+ VALUES (?, ?, ?, ?, ?)
526
+ """,
527
+ (self._session_key, image_uuid, parent_id, kind, title),
528
+ )
529
+ new_id = cur.lastrowid
530
+ assert new_id is not None
531
+
532
+ # Advance branch pointer
533
+ self._conn.execute(
534
+ """
535
+ INSERT INTO session_branches
536
+ (session_key, branch_name, history_id)
537
+ VALUES (?, ?, ?)
538
+ ON CONFLICT(session_key, branch_name) DO UPDATE SET
539
+ history_id = excluded.history_id
540
+ """,
541
+ (self._session_key, branch, new_id),
542
+ )
543
+
544
+ # Upsert session state
545
+ self._conn.execute(
546
+ """
547
+ INSERT INTO session_state
548
+ (session_key, active_branch, updated_at)
549
+ VALUES (?, 'main', strftime('%Y-%m-%dT%H:%M:%S','now'))
550
+ ON CONFLICT(session_key) DO UPDATE SET
551
+ updated_at = strftime('%Y-%m-%dT%H:%M:%S','now')
552
+ """,
553
+ (self._session_key,),
554
+ )
555
+ self._conn.commit()
556
+ return new_id
557
+
558
+ def add_pending_file(
559
+ self,
560
+ history_id: int,
561
+ instance_path: str,
562
+ file_uuid: str,
563
+ uid: int = 0,
564
+ gid: int = 0,
565
+ mode: str = "0644",
566
+ ) -> None:
567
+ self._conn.execute(
568
+ """
569
+ INSERT INTO session_files
570
+ (history_id, instance_path, file_uuid, uid, gid, mode)
571
+ VALUES (?, ?, ?, ?, ?, ?)
572
+ """,
573
+ (history_id, instance_path, file_uuid, uid, gid, mode),
574
+ )
575
+ self._conn.commit()
576
+
577
+ def pending_files(self) -> list[PendingFile]:
578
+ """Return pending files that haven't been baked in by a run yet.
579
+
580
+ Walks history from the branch tip backwards, collecting
581
+ ``kind='file'`` entry IDs until hitting a ``kind='run'`` entry.
582
+ Looks up the corresponding file records in session_files.
583
+ """
584
+ branch = self._active_branch()
585
+ if branch is None:
586
+ return []
587
+ cur = self._conn.execute(
588
+ "SELECT history_id FROM session_branches "
589
+ "WHERE session_key = ? AND branch_name = ?",
590
+ (self._session_key, branch),
591
+ )
592
+ row = cur.fetchone()
593
+ if row is None:
594
+ return []
595
+
596
+ # Walk history from tip, collect file entry IDs until a run
597
+ file_history_ids: list[int] = []
598
+ history_id: int = row["history_id"]
599
+ while True:
600
+ entry = self._get_history_entry(history_id)
601
+ if entry.kind == "run":
602
+ break
603
+ if entry.kind == "file":
604
+ file_history_ids.append(entry.id)
605
+ if entry.parent_id is None:
606
+ break
607
+ history_id = entry.parent_id
608
+
609
+ if not file_history_ids:
610
+ return []
611
+
612
+ placeholders = ",".join("?" for _ in file_history_ids)
613
+ cur = self._conn.execute(
614
+ "SELECT instance_path, file_uuid, uid, gid, mode "
615
+ "FROM session_files "
616
+ f"WHERE history_id IN ({placeholders}) "
617
+ "ORDER BY history_id DESC",
618
+ tuple(file_history_ids),
619
+ )
620
+ # Deduplicate by path — most recent edit (highest history_id) wins
621
+ seen: set[str] = set()
622
+ result: list[PendingFile] = []
623
+ for r in cur.fetchall():
624
+ if r["instance_path"] not in seen:
625
+ seen.add(r["instance_path"])
626
+ result.append(PendingFile(
627
+ instance_path=r["instance_path"],
628
+ file_uuid=r["file_uuid"],
629
+ uid=r["uid"],
630
+ gid=r["gid"],
631
+ mode=r["mode"],
632
+ ))
633
+ return result
634
+
635
+ def clear_pending_files(self) -> int:
636
+ cur = self._conn.execute(
637
+ "DELETE FROM session_files "
638
+ "WHERE history_id IN ("
639
+ " SELECT id FROM session_history "
640
+ " WHERE session_key = ?"
641
+ ")",
642
+ (self._session_key,),
643
+ )
644
+ self._conn.commit()
645
+ return cur.rowcount
646
+
647
+ def get_cwd(self) -> str:
648
+ """Return the current working directory from session history.
649
+
650
+ Walks the history chain from the branch tip backwards until a
651
+ ``kind='cd'`` entry is found. Returns its *title* (the path).
652
+ Returns ``""`` when no ``cd`` entry exists or no session is active.
653
+ """
654
+ branch = self._active_branch()
655
+ if not branch:
656
+ return ""
657
+ cur = self._conn.execute(
658
+ "SELECT history_id FROM session_branches "
659
+ "WHERE session_key = ? AND branch_name = ?",
660
+ (self._session_key, branch),
661
+ )
662
+ row = cur.fetchone()
663
+ if not row:
664
+ return ""
665
+ history_id: int = row["history_id"]
666
+ while True:
667
+ entry = self._get_history_entry(history_id)
668
+ if entry.kind == "cd":
669
+ return entry.title
670
+ if entry.parent_id is None:
671
+ return ""
672
+ history_id = entry.parent_id
673
+
674
+ def resolve_path(self, path: str) -> str:
675
+ """Resolve a container path against the session cwd.
676
+
677
+ Relative paths are joined with cwd. The result is always
678
+ normalised. Returns cwd (or ``/``) for empty input.
679
+ """
680
+ if not path:
681
+ return self.get_cwd() or "/"
682
+ if not PurePosixPath(path).is_absolute():
683
+ cwd = self.get_cwd() or "/"
684
+ path = cwd.rstrip("/") + "/" + path
685
+ return posixpath.normpath(path)
686
+
687
+ def set_cwd(self, cwd: str) -> None:
688
+ """Store the cwd as a ``kind='cd'`` entry in session history."""
689
+ s = self.session
690
+ if s is None:
691
+ return # No active session — nothing to persist
692
+ self.set_image(s.current_image, kind="cd", title=cwd)
693
+
694
+ def load_shell_history(self) -> list[str]:
695
+ """Return shell history lines for this session, oldest first."""
696
+ cur = self._conn.execute(
697
+ "SELECT line FROM shell_history "
698
+ "WHERE session_key = ? ORDER BY id",
699
+ (self._session_key,),
700
+ )
701
+ return [row["line"] for row in cur.fetchall()]
702
+
703
+ def add_shell_history(self, line: str) -> None:
704
+ """Append a single line to the shell history."""
705
+ self._conn.execute(
706
+ "INSERT INTO shell_history (session_key, line) "
707
+ "VALUES (?, ?)",
708
+ (self._session_key, line),
709
+ )
710
+ self._conn.commit()
711
+
712
+ def trim_shell_history(self) -> None:
713
+ """Delete the oldest entries that exceed the maximum."""
714
+ self._conn.execute(
715
+ "DELETE FROM shell_history WHERE id IN ("
716
+ " SELECT id FROM shell_history "
717
+ " WHERE session_key = ? "
718
+ " ORDER BY id "
719
+ " LIMIT MAX(0, ("
720
+ " SELECT COUNT(*) FROM shell_history "
721
+ " WHERE session_key = ?"
722
+ " ) - ?)"
723
+ ")",
724
+ (
725
+ self._session_key,
726
+ self._session_key,
727
+ self.MAX_SHELL_HISTORY,
728
+ ),
729
+ )
730
+ self._conn.commit()
731
+
732
+ def close(self) -> None:
733
+ self._conn.close()
734
+
735
+
736
+ def get_session_key(profile_name: str) -> str:
737
+ env = os.environ.get("CONTREE_SESSION")
738
+ if env:
739
+ return env
740
+
741
+ ppid = os.getppid()
742
+ try:
743
+ tty = os.ttyname(sys.stdin.fileno())
744
+ tty_part = tty.replace("/", "_")
745
+ except OSError:
746
+ tty_part = "notty"
747
+ return str(
748
+ uuid.uuid5(
749
+ uuid.NAMESPACE_OID,
750
+ f"{profile_name}_{ppid}_{tty_part}",
751
+ ),
752
+ )
753
+
754
+
755
+ def get_db_path() -> Path:
756
+ env = os.environ.get("CONTREE_SESSION_DB")
757
+ if env:
758
+ return Path(env)
759
+ return (
760
+ Path.home() / ".local" / "lib" / "contree-cli" / "sessions.db"
761
+ )