Skip to content

Commit 9702bf5

Browse files
feat: parentUuid auto-relink on save + restore subcommand (#2)
* feat: parentUuid auto-relink on save + restore subcommand Fixes the failure mode where scattered deletes left surviving messages pointing to deleted ancestors, making Claude Code render the session as empty on resume. - Session::render_with_relink rewrites parentUuid for survivors whose ancestor was deleted, walking up to the nearest surviving ancestor (or null at the root). Foreign parent uuids are preserved verbatim. - 'delete' output reports parent_uuid_relinked count. - New 'cc-session restore <id> [--list]' rolls back from .bak with a pre-restore snapshot saved aside as <path>.pre-restore.<ts>. - 4 new unit tests cover relink-skips-deleted, relink-to-root, no-change-byte-equal, and foreign-parent-preserved. * release: v0.3.0
1 parent 2853409 commit 9702bf5

6 files changed

Lines changed: 360 additions & 22 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cc-session"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2021"
55
rust-version = "1.75"
66
description = "Interactive TUI editor for Claude Code session JSONL files. Browse, search, and surgically delete messages while preserving tool_use/tool_result pairing."

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ cc-session search <query> [--json] [--limit N]
7575
cc-session show <id-or-path> [--json] [--full] [--include-hidden]
7676
cc-session info <id-or-path> [--json]
7777
cc-session delete <id-or-path> --indices 3,5,7 [--from-top N] [--from-bottom N] [--range lo..hi] [--dry-run] [--force] [--json]
78-
cc-session update [--version v0.2.0]
78+
cc-session update [--version v0.2.0]
79+
cc-session restore <id-or-path> [--list] [--json]
7980
```
8081
`<id-or-path>` accepts a full path, a session UUID, or a unique substring of one. Indices are 0-based positions in the raw JSONL (use `cc-session show --json` to map text → index). Auto-pair always extends the delete set to keep `tool_use`/`tool_result` blocks together; `paired_added` in the output reports what was added.
8182

src/cli.rs

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ pub fn show(
248248
#[derive(Serialize)]
249249
struct DeleteOutput {
250250
path: String,
251+
parent_uuid_relinked: usize,
251252
backup: Option<String>,
252253
requested: Vec<usize>,
253254
after_auto_pair: Vec<usize>,
@@ -327,7 +328,7 @@ pub fn delete(
327328
));
328329
}
329330

330-
let content = session.render(&marked)?;
331+
let (content, relinked) = session.render_with_relink(&marked)?;
331332
let after = total - marked.len();
332333

333334
let (saved, backup) = if dry_run {
@@ -344,6 +345,7 @@ pub fn delete(
344345

345346
let out = DeleteOutput {
346347
path: entry.path.display().to_string(),
348+
parent_uuid_relinked: relinked,
347349
backup,
348350
requested: requested_sorted,
349351
after_auto_pair: all_sorted,
@@ -368,6 +370,7 @@ pub fn delete(
368370
out.total_messages_after,
369371
out.total_messages_before - out.total_messages_after
370372
);
373+
println!("parent_uuid relinked: {}", out.parent_uuid_relinked);
371374
println!("dry_run: {}", out.dry_run);
372375
println!("saved: {}", out.saved);
373376
if let Some(b) = &out.backup {
@@ -474,6 +477,158 @@ pub fn info(projects_dir: &Path, target: &str, json: bool) -> Result<()> {
474477
Ok(())
475478
}
476479

480+
// ---------- restore ----------
481+
482+
#[derive(Serialize)]
483+
struct RestoreOutput {
484+
path: String,
485+
backup: String,
486+
pre_restore_snapshot: Option<String>,
487+
backup_messages: usize,
488+
current_messages: Option<usize>,
489+
backup_size: u64,
490+
backup_modified: String,
491+
listed_only: bool,
492+
restored: bool,
493+
}
494+
495+
pub fn restore(
496+
projects_dir: &Path,
497+
target: &str,
498+
list_only: bool,
499+
force: bool,
500+
json: bool,
501+
) -> Result<()> {
502+
let entry = resolve_target(projects_dir, target)?;
503+
let bak_path = bak_path_for(&entry.path);
504+
if !bak_path.exists() {
505+
bail!(
506+
"no backup found at {} — cc-session writes <file>.bak on every save",
507+
bak_path.display()
508+
);
509+
}
510+
511+
// Sanity-check the backup parses; we don't want to restore a corrupt file.
512+
let backup_session = Session::load(&bak_path)?;
513+
let backup_messages = backup_session.messages.len();
514+
515+
let bak_meta = std::fs::metadata(&bak_path)?;
516+
let bak_size = bak_meta.len();
517+
let bak_mtime: DateTime<Local> = bak_meta
518+
.modified()
519+
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
520+
.into();
521+
let bak_modified = bak_mtime.format("%Y-%m-%d %H:%M:%S").to_string();
522+
523+
let current_messages = if entry.path.exists() {
524+
Session::load(&entry.path).ok().map(|s| s.messages.len())
525+
} else {
526+
None
527+
};
528+
529+
if list_only {
530+
let out = RestoreOutput {
531+
path: entry.path.display().to_string(),
532+
backup: bak_path.display().to_string(),
533+
pre_restore_snapshot: None,
534+
backup_messages,
535+
current_messages,
536+
backup_size: bak_size,
537+
backup_modified: bak_modified,
538+
listed_only: true,
539+
restored: false,
540+
};
541+
if json {
542+
println!("{}", serde_json::to_string_pretty(&out)?);
543+
} else {
544+
println!("path: {}", out.path);
545+
println!("backup: {}", out.backup);
546+
println!("backup msgs: {}", out.backup_messages);
547+
if let Some(c) = out.current_messages {
548+
println!("current msgs: {c}");
549+
} else {
550+
println!("current msgs: (file missing)");
551+
}
552+
println!("backup size: {}", human_size(out.backup_size));
553+
println!("backup mtime: {}", out.backup_modified);
554+
}
555+
return Ok(());
556+
}
557+
558+
if !force && entry.path.exists() && super::io::lsof::is_open(&entry.path)? {
559+
bail!("file is open by another process; close Claude Code or pass --force");
560+
}
561+
562+
// If a current file exists, snapshot it aside before overwriting so the
563+
// restore itself is reversible. Use a sibling path that does NOT match
564+
// *.bak (which we'd clobber on next save).
565+
let snapshot = if entry.path.exists() {
566+
let snap = pre_restore_snapshot_path(&entry.path);
567+
std::fs::copy(&entry.path, &snap)?;
568+
Some(snap)
569+
} else {
570+
None
571+
};
572+
573+
// Atomic restore: copy bak -> <path>.tmp, fsync, rename.
574+
let tmp = with_extension_appended(&entry.path, "tmp");
575+
std::fs::copy(&bak_path, &tmp)?;
576+
{
577+
let f = std::fs::OpenOptions::new().write(true).open(&tmp)?;
578+
f.sync_all()?;
579+
}
580+
std::fs::rename(&tmp, &entry.path)?;
581+
582+
let out = RestoreOutput {
583+
path: entry.path.display().to_string(),
584+
backup: bak_path.display().to_string(),
585+
pre_restore_snapshot: snapshot.map(|p| p.display().to_string()),
586+
backup_messages,
587+
current_messages,
588+
backup_size: bak_size,
589+
backup_modified: bak_modified,
590+
listed_only: false,
591+
restored: true,
592+
};
593+
594+
if json {
595+
println!("{}", serde_json::to_string_pretty(&out)?);
596+
} else {
597+
println!("restored: {}", out.path);
598+
println!("from: {}", out.backup);
599+
if let Some(s) = &out.pre_restore_snapshot {
600+
println!("prev: {s} (snapshot of state before restore)");
601+
}
602+
println!(
603+
"messages: {} (was {})",
604+
out.backup_messages,
605+
out.current_messages
606+
.map(|n| n.to_string())
607+
.unwrap_or_else(|| "missing".into())
608+
);
609+
}
610+
Ok(())
611+
}
612+
613+
fn bak_path_for(path: &Path) -> PathBuf {
614+
with_extension_appended(path, "bak")
615+
}
616+
617+
fn pre_restore_snapshot_path(path: &Path) -> PathBuf {
618+
let stamp = std::time::SystemTime::now()
619+
.duration_since(std::time::UNIX_EPOCH)
620+
.map(|d| d.as_secs())
621+
.unwrap_or(0);
622+
with_extension_appended(path, &format!("pre-restore.{stamp}"))
623+
}
624+
625+
fn with_extension_appended(path: &Path, suffix: &str) -> PathBuf {
626+
let mut s = path.as_os_str().to_owned();
627+
s.push(".");
628+
s.push(suffix);
629+
PathBuf::from(s)
630+
}
631+
477632
// ---------- agent guide ----------
478633

479634
pub const AGENT_GUIDE: &str = r#"# cc-session agent guide
@@ -503,6 +658,10 @@ while keeping tool_use/tool_result pairs and conversational turns intact.
503658
bypasses the lsof safety check.
504659
5. (Optional) self-update:
505660
cc-session update [--version v0.2.0]
661+
6. If a delete breaks resume in Claude Code, restore from backup:
662+
cc-session restore <id> --list # inspect first
663+
cc-session restore <id> # apply (snapshots current
664+
# to <path>.pre-restore.<ts>)
506665
507666
## Target argument (<id-or-path>)
508667
@@ -541,6 +700,9 @@ The delete output reports `requested` (what you asked) and `paired_added`
541700
542701
{
543702
"path": "<absolute path>",
703+
"parent_uuid_relinked": int, // survivors whose parentUuid
704+
// was rewritten to skip
705+
// deleted ancestors
544706
"backup": "<path>.bak | null when --dry-run",
545707
"requested": [int, ...], // sorted, what you asked
546708
"after_auto_pair": [int, ...], // sorted, final delete set
@@ -633,6 +795,15 @@ Always inspect stderr on non-zero exit for the human-readable cause.
633795
cc-session search "auth middleware" --json --limit 1
634796
cc-session show <id-from-above> --json
635797
798+
## Resume safety: parentUuid auto-relink
799+
800+
Every save scans surviving messages and rewrites any `parentUuid` that
801+
points to a now-deleted ancestor, walking up the chain to the nearest
802+
surviving ancestor (or null if the chain reaches the root). The count is
803+
reported in `parent_uuid_relinked`. This keeps Claude Code's resume
804+
renderer happy after scattered deletes; if it ever fails anyway,
805+
`cc-session restore <id>` rolls back to the .bak snapshot.
806+
636807
## Things this CLI will NOT do
637808
638809
- Edit message contents in place.

src/main.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ enum Command {
105105
/// exit codes. Designed for LLMs and scripts to read once and operate
106106
/// autonomously.
107107
AgentGuide,
108+
/// Restore a session from its <file>.bak backup. Refuses to overwrite
109+
/// while Claude Code holds the file open unless --force.
110+
Restore {
111+
/// Session id, file path, or substring of either.
112+
target: String,
113+
/// Just print the backup path and metadata; don't restore.
114+
#[arg(long)]
115+
list: bool,
116+
/// Output JSON.
117+
#[arg(long)]
118+
json: bool,
119+
},
108120
}
109121

110122
fn main() -> anyhow::Result<()> {
@@ -160,6 +172,9 @@ fn main() -> anyhow::Result<()> {
160172
print!("{}", cli::AGENT_GUIDE);
161173
Ok(())
162174
}
175+
Some(Command::Restore { target, list, json }) => {
176+
cli::restore(&projects_dir, &target, list, cli.force, json)
177+
}
163178
}
164179
}
165180

0 commit comments

Comments
 (0)