use super::*;
#[derive(Debug, Args)]
#[command(
about = "Reconstruct a file's history from the transcript: segmented diff-patches, \
point-in-time partial snapshot, or coverage scoping",
long_about = "Reconstruct a single file's history from a session transcript. Unlike \
`files` (which only ROLLS UP that a file was touched), `recover` rebuilds the \
file's CONTENT line-by-line from the transcript's Reads / Writes / Edits, in \
transcript order, with every output line carrying the JSONL LINE NUMBER so an \
LLM can `Read` the raw jsonl directly.\n\n\
FIVE MUTUALLY-EXCLUSIVE MODES (exactly one; default = restore):\n \
(default, no mode flag) RESTORE the file's FINAL content as RAW restorable \
bytes: what you'd `> file` to put it back. Restore SUCCEEDS only when the \
session saw the WHOLE file (a full Read, or it authored the file outright); it \
then prints the reconstructed content with NO line numbers / banners (clean for \
piping). When the session observed just PART of the file (a windowed read + a few \
edits), restore FAILS LOUDLY; it never hands back a holey file, naming the \
line ranges it CAN and CANNOT recover and pointing at `--salvage`.\n \
--salvage restore's never-fails sibling: the best-effort, line-numbered \
FINAL-state fragment. Dumps whatever survived (each known line numbered) with the \
unrecoverable lines left as explicit `??? lines A..B unknown` gaps. For a file \
that is GONE, only-partially-read, and barely-edited, where rewinding isn't the \
goal and salvaging the surviving proportion is. Identical output to `--at @latest`, \
framed as the dead-file salvage front door. (Content invalidated by a \
`modified since read` boundary is dropped, not shown stale.)\n \
--patches segmented unified-diff history of `--file`. The range \
is split at INTEGRITY BOUNDARIES: points where reconstruction across them is \
invalid (a `File has been modified since read` harness error, an `originalFile` \
that disagrees with the replayed buffer, an external `edited_text_file`, or a \
heuristic Bash mutation). Each segment + boundary carries its jsonl line / turn \
/ timestamp. This is the CHANGES view: rewind a still-present file to an older \
state over a `--since`/`--until` window; most useful when ONLY this session \
touched the file and it did NOT read it in full.\n \
--at <WHEN> the PARTIAL, line-numbered \"in the LLM's eyes\" snapshot of \
`--file` as of <WHEN> (the SAME relative/ISO/bare-date grammar as --since: \
`45s`/`90m`/`2h`/`3d`/`1w`, ISO8601, bare-date=local-midnight, PLUS the \
recover-only `@turn:<N>` / `@line:<N>` / `@latest`). Unlike restore, `--at` will \
dump a PARTIAL snapshot: known lines carry their number; unknown regions are \
marked `??? lines A..B unknown`; gaps are NEVER fabricated. `--at @latest` is \
the partial-tolerant sibling of the default restore (final state, holes shown).\n \
--coverage (alias --dry-run) scope a recovery WITHOUT dumping content: which \
line ranges are recoverable, where the integrity boundaries sit, and per-op \
counts (reads / edits / writes / bash / external-edits).\n\n\
The TARGET selects the session(s): `@<uuid>` for one, or a project \
PATH/encoded-dir for every session under it. `--no-subagents` restricts to the \
top-level session (OMC fan-out edits happen in subagents, so default ON).\n\n\
WINDOWING: `--turn (N|A..B|N..|-k)` (inclusive, 0-based genuine-user order) \
INTERSECTS with `--since`/`--until` (ISO8601 / relative; both filters AND). \
`--file-lines (N|A..B|N..|-k)` further restricts to a 1-based FILE-line span. `--out <PATH>` writes \
the reconstructed artifact (restored content / snapshot / concatenated patches) \
verbatim to a file; in restore mode stdout then stays empty (just a stderr note), \
in the other modes the summary still prints to stdout.\n\n\
Reconstruction is NECESSARILY PARTIAL and NEVER fabricates: an unseen line is \
an explicit gap, an un-anchorable edit is a coverage hole, a Bash touch is a \
heuristic (not authoritative) boundary. No silent truncation.",
after_help = "MODE (choose AT MOST ONE; default = restore)\n \
--salvage / --patches / --at <WHEN> / --coverage / --list-backups are MUTUALLY \
EXCLUSIVE; \
passing two (e.g. `--coverage --patches`) is a parse error. With NONE, the default \
RESTORE mode applies: it hands back the file's final content, or FAILS (never a \
partial file) when the session saw only part of it; reach for `--salvage` then. \
`--file` is REQUIRED for every mode; its value is an absolute path OR the magic \
`@plan` (the session-bound plan file).\n\n\
EXAMPLES\n \
csift recover @<uuid> --file /abs/app.py # DEFAULT: restore final content (or fail if only partial)\n \
csift recover @<uuid> --file /abs/app.py --out /abs/app.py # restore straight back onto disk (raw bytes, no banners)\n \
csift recover @<uuid> --file /abs/gone.py --salvage # file is gone + only partly seen: dump what survived, gaps explicit\n \
csift recover . --file /abs/PLAN.md --coverage # scope first: covered ranges + boundaries, no dump\n \
csift recover --file /abs/app.py --list-backups # CC's own rewind checkpoints of this path (store listing, no transcript scan)\n \
csift recover @<uuid> --file /abs/app.py --patches # segmented unified diffs over the whole session\n \
csift recover @<uuid> --file /abs/app.py --since 2h # patches for the last 2h only (rewind a window)\n \
csift recover @<uuid> --file /abs/app.py --at @latest # partial-tolerant final snapshot (holes shown)\n \
csift recover @<uuid> --file /abs/app.py --at @turn:42 # partial snapshot as the LLM saw it at turn 42\n \
csift recover @<uuid> --file @plan --out /tmp/plan.md # reconstruct the session's bound plan (even if deleted)\n \
csift recover @<uuid> --file /abs/x.rs --file-lines 100..200 --patches # only patches touching FILE lines 100-200\n\n\
WINDOW ACCOUNTING (every mode)\n \
Recover never pretends a window is clean. Beside the replayed events it \
counts, per window: parsed bash mutations OF the file (each disclosed as a \
boundary), OPAQUE mutating-class commands (a formatter run, a package-manager \
install, an archive/patch extraction, an interpreter write with an \
unextractable target: commands that mutate files they never name, shown as \
`fmt:`/`pkg:`/`extract:`/`interp:` markers), and PowerShell commands (their \
command text is never lexically parsed). When any are present the output says \
so and prints a ready-to-run, time-bounded `csift search` that lists the \
window's tool calls touching the file. `complete` therefore means complete \
FROM THE TOOL STREAM; only a clean window note implies nothing else ran. A \
`--turn`/`--since`/`--until` window that excludes integrity-relevant events \
prints a note naming how many fell outside.\n\n\
BASH CONTENT ANCHORS (deterministic shell reads/writes join the replay)\n \
A small closed set of shell shapes carries the file content ITSELF, so it \
replays as first-class events instead of degrading to a boundary: a \
quoted-delimiter heredoc written via `cat`/`tee` (the body is byte-verbatim \
in the transcript; an unquoted delimiter is admitted only with an \
expansion-free body); `echo`/`printf` with purely literal arguments; \
`truncate -s 0`; and on the READ side `cat <file>`, `head -n N <file>`, and \
`sed -n 'A,Bp' <file>`, whose stdout is the file window when the result echo \
is clean (empty stderr, not interrupted, not externalized, exit ok). Every \
anchor demands a SINGLE simple command with a literal operand resolved \
against the recording shell's cwd; pipes, chains, substitutions, variable \
targets, interpreter heredocs (a script is not file content), and ssh-fed \
heredocs (a REMOTE filesystem) all stay in the boundary lanes. An admitted \
write anchor supersedes its own heuristic bash-mutation boundary. A \
byte-known `>>` append is PLACED only onto a complete newline-terminated \
buffer; anywhere else it is disclosed as a `bash_append_unplaced` boundary \
(content known, position unknowable). `tail` output is never placeable in a \
line-keyed buffer without the file length, so it is deliberately not an \
anchor; `sed -i` rewrites are likewise left heuristic. Coverage counts the \
admitted anchors as `bash-read-anchor`/`bash-write-anchor`, and segment \
provenance names the class (`bash-heredoc`/`bash-cat`/`bash-write`).\n\n\
FILE-HISTORY SNAPSHOTS (Claude Code's own backups, adopted as rebase anchors)\n \
Claude Code snapshots every Edit/Write-tracked file per prompt and bumps its \
`version` only when the bytes changed - so the version sequence records DISK \
TRUTH, including writes with NO tool record (the harness rewriting its \
settings on /model or /config, an outside editor). recover reads the sequence \
from the transcript and, where the store blob under \
`<claude-home>/file-history/` survives AND its mtime matches the recorded \
backup instant (the @vN name collides across a mid-session counter reset, so \
an unverified blob is refused), COMPARES the replayed buffer against the \
snapshot content at each version change: a disagreement is an authoritative \
`external_write` boundary and the replay REBASES on the snapshot bytes - the \
never-existed state a silent write used to produce is gone. Without the blob, \
a version jump with no tool write since the previous snapshot still discloses \
the same boundary content-less (nothing rebased, trust ends there). A tool \
write and a silent write inside ONE snapshot interval merge into one bump; \
the content comparison is the complete detector, the number alone is not.\n\n\
FRESHNESS SIGNALS (Claude Code's own, adopted as boundaries)\n \
A Bash result's `staleReadFileStateHint` is Claude Code itself reporting \
that the command modified files in its read set, BY NAME (paths relative to \
the shell cwd, resolved before matching): an authoritative HARD \
`hint_modified` boundary, and the one signal that attributes a formatter-class \
rewrite to concrete files. A successful Edit flagged `staleRecovered` means \
the disk had drifted since the last read but the edit still applied: an \
authoritative `stale_recovered` annotation (nothing invalidated, other \
changes exist outside the stream). An `edited_text_file` external-edit \
boundary whose window also ran a formatter-class command names that command \
in its detail (the change may be the formatter's rewrite; check the \
project's conventions). Failed ops (`String to replace not found`, `File \
does not exist`) are counted in the event ledger, never replayed.\n\n\
JSON SCHEMA (per --format json)\n \
EVERY mode (restore included) emits the envelope: one `{kind:\"header\",…}` \
line first, one `{kind:\"summary\", file, mode, sessions, skipped_lines}` line \
last. RESTORE emits one `{kind:\"restore\", file, complete, lines, \
boundaries, bash_events, opaque_commands, powershell_commands, \
suggested_search}` row carrying `content` (or `path` + `wrote` with `--out`); \
a partial or no-history run still emits its `{kind:\"restore\", complete:false, \
reason}` row + the summary, then errors on stderr with a non-zero exit. \
`--patches` emits `{kind:\"segment\",…}` + `{kind:\"boundary\",…}` rows; \
`--coverage` emits `{kind:\"coverage\", covered_ranges, boundaries, \
hard_boundaries, soft_boundaries, events, fragments, recoverable_lines, \
opaque_commands, powershell_commands, suggested_search, …}`; `--at` and \
`--salvage` emit `{kind:\"snapshot\", lines, gaps, seen_total_lines, \
boundaries, opaque_commands, powershell_commands, suggested_search, …}`. A \
boundary object is `{line, source_session_id, source_line, turn_index, \
ts_utc, ts_local, cause, confidence, detail}`: `line` is the replay/cutoff \
coordinate (feed it to `--at @line:<N>`), `source_line` the REAL jsonl line in \
`source_session_id` (feed those to `csift show`); the two pairs differ only \
after a cross-transcript merge. Every per-session row carries the id-domain \
discriminators `{session_id, is_subagent, parent_session_id}` (is_subagent \
flags a bare-hex subagent record; re-feed parent_session_id, never the bare \
session_id)."
)]
pub struct RecoverArgs {
#[arg(
value_name = "PATH",
allow_hyphen_values = true,
value_parser = parse_project_target
)]
pub paths: Vec<PathBuf>,
#[arg(long = "sessions-from", value_name = "FILE|-")]
pub sessions_from: Option<std::path::PathBuf>,
#[arg(long, value_name = "ABS_PATH")]
pub file: Option<String>,
#[arg(long = "no-subagents")]
pub no_subagents: bool,
#[arg(long = "subagents", conflicts_with = "no_subagents")]
pub subagents: bool,
#[arg(long, group = "mode")]
pub salvage: bool,
#[arg(long, group = "mode")]
pub patches: bool,
#[arg(long, value_name = "WHEN", group = "mode")]
pub at: Option<String>,
#[arg(long, visible_alias = "dry-run", group = "mode")]
pub coverage: bool,
#[arg(long = "list-backups", group = "mode", conflicts_with = "files_from")]
pub list_backups: bool,
#[arg(
long = "turn",
value_name = "N|A..B|N..|-k",
allow_hyphen_values = true
)]
pub turn_range: Option<String>,
#[arg(long, value_name = "WHEN")]
pub since: Option<String>,
#[arg(long, value_name = "WHEN")]
pub until: Option<String>,
#[arg(
long = "file-lines",
value_name = "N|A..B|N..|-k",
allow_hyphen_values = true
)]
pub line_range: Option<String>,
#[arg(long, value_name = "PATH")]
pub out: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
#[arg(long, value_name = "MANIFEST")]
pub files_from: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
pub out_dir: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
impl RecoverArgs {
#[must_use]
pub fn want_subagents(&self) -> bool {
self.subagents || !self.no_subagents
}
#[must_use]
pub fn mode(&self) -> RecoverMode {
if self.at.is_some() {
RecoverMode::At
} else if self.coverage {
RecoverMode::Coverage
} else if self.patches {
RecoverMode::Patches
} else if self.salvage {
RecoverMode::Salvage
} else {
RecoverMode::Restore
}
}
}