Skip to content

Commit a090d7d

Browse files
gaoyu06claude
andcommitted
feat(checkpoint): git file snapshots complete codex/claude rewind (phase 2)
Before each codex/claude turn the working tree is snapshotted as a dangling commit (git_checkpoint_capture — isolated temp index, tracked + untracked, never touches the real index/worktree). A rewind now also restores files to the target turn's checkpoint (git_checkpoint_restore), which snapshots the current state as a recovery commit first so nothing is ever unrecoverable; files created after the checkpoint are left in place (never deletes untracked work). Dedicated Tauri commands keep the security-restricted general git bridge unchanged. Verified with a real-git capture→modify→restore integration test. This completes #31: codex (thread/rollback) and claude (--resume-session-at) both rewind conversation + files, with no Agent SDK / Node sidecar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 97ef1ac commit a090d7d

4 files changed

Lines changed: 166 additions & 2 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1730,6 +1730,81 @@ fn git(args: Vec<String>, cwd: Option<String>) -> Result<String, String> {
17301730
}
17311731
}
17321732

1733+
/// Runs a fixed git plumbing command in `dir` (optionally with an isolated index
1734+
/// file). A stable checkpoint identity is set so `commit-tree` works even in a
1735+
/// repo without a configured user. Returns trimmed stdout on success.
1736+
fn git_plumb(dir: &Path, index: Option<&Path>, args: &[&str]) -> Result<String, String> {
1737+
let mut cmd = Command::new("git");
1738+
shell_env::merge_into(&mut cmd);
1739+
cmd.args(args)
1740+
.current_dir(dir)
1741+
.env("GIT_TERMINAL_PROMPT", "0")
1742+
.env("GIT_AUTHOR_NAME", "JuCode")
1743+
.env("GIT_AUTHOR_EMAIL", "checkpoint@jucode.local")
1744+
.env("GIT_COMMITTER_NAME", "JuCode")
1745+
.env("GIT_COMMITTER_EMAIL", "checkpoint@jucode.local");
1746+
if let Some(idx) = index {
1747+
cmd.env("GIT_INDEX_FILE", idx);
1748+
}
1749+
let output = cmd
1750+
.output()
1751+
.map_err(|e| format!("failed to run git: {e}"))?;
1752+
if output.status.success() {
1753+
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1754+
} else {
1755+
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
1756+
}
1757+
}
1758+
1759+
/// Snapshot the full working tree (tracked + untracked) as a dangling commit
1760+
/// object, WITHOUT touching the user's index or working tree (uses an isolated
1761+
/// temp index). Returns the commit sha — a rewind file-checkpoint. `Ok(String::new())`
1762+
/// only if the tree is unwritable; errors bubble the git message.
1763+
#[tauri::command(async)]
1764+
fn git_checkpoint_capture(cwd: String) -> Result<String, String> {
1765+
let dir = PathBuf::from(&cwd);
1766+
let idx = std::env::temp_dir().join(format!("jucode-ckpt-{}.idx", std::process::id()));
1767+
let _ = std::fs::remove_file(&idx);
1768+
let has_head = git_plumb(&dir, None, &["rev-parse", "--verify", "HEAD"]).is_ok();
1769+
if has_head {
1770+
git_plumb(&dir, Some(&idx), &["read-tree", "HEAD"])?;
1771+
}
1772+
git_plumb(&dir, Some(&idx), &["add", "-A"])?;
1773+
let tree = git_plumb(&dir, Some(&idx), &["write-tree"])?;
1774+
let _ = std::fs::remove_file(&idx);
1775+
let commit = if has_head {
1776+
let head = git_plumb(&dir, None, &["rev-parse", "HEAD"])?;
1777+
git_plumb(
1778+
&dir,
1779+
None,
1780+
&["commit-tree", &tree, "-p", &head, "-m", "jucode-checkpoint"],
1781+
)?
1782+
} else {
1783+
git_plumb(&dir, None, &["commit-tree", &tree, "-m", "jucode-checkpoint"])?
1784+
};
1785+
Ok(commit)
1786+
}
1787+
1788+
/// Restore the working tree + index to a checkpoint commit. First snapshots the
1789+
/// CURRENT state as a recovery commit (returned, so nothing is ever unrecoverable),
1790+
/// then restores the checkpoint's paths. Files created after the checkpoint are
1791+
/// left in place — this never deletes untracked work.
1792+
#[tauri::command(async)]
1793+
fn git_checkpoint_restore(cwd: String, checkpoint: String) -> Result<String, String> {
1794+
if checkpoint.len() < 7
1795+
|| checkpoint.len() > 64
1796+
|| !checkpoint.chars().all(|c| c.is_ascii_hexdigit())
1797+
{
1798+
return Err(format!("invalid checkpoint sha: {checkpoint}"));
1799+
}
1800+
let dir = PathBuf::from(&cwd);
1801+
git_plumb(&dir, None, &["cat-file", "-e", &checkpoint])
1802+
.map_err(|_| format!("checkpoint object not found: {checkpoint}"))?;
1803+
let safety = git_checkpoint_capture(cwd.clone())?;
1804+
git_plumb(&dir, None, &["checkout", &checkpoint, "--", "."])?;
1805+
Ok(safety)
1806+
}
1807+
17331808
/// Resolves the GitHub CLI binary: PATH first, then the usual install locations
17341809
/// (a packaged .app inherits a minimal PATH from launchd).
17351810
fn resolve_gh() -> PathBuf {
@@ -2139,6 +2214,8 @@ pub fn run() {
21392214
fetch_deepseek_balance,
21402215
transcribe_audio,
21412216
generate_text,
2217+
git_checkpoint_capture,
2218+
git_checkpoint_restore,
21422219
project_root,
21432220
list_providers,
21442221
list_dir,
@@ -2199,6 +2276,45 @@ mod tests {
21992276
assert_eq!(read_json_strict(&p).unwrap(), serde_json::json!({}));
22002277
}
22012278

2279+
#[test]
2280+
fn checkpoint_capture_and_restore_roundtrip() {
2281+
use std::process::Command;
2282+
let dir = std::env::temp_dir().join(format!("jucode-ckpt-it-{}", std::process::id()));
2283+
let _ = std::fs::remove_dir_all(&dir);
2284+
std::fs::create_dir_all(&dir).unwrap();
2285+
let git = |args: &[&str]| {
2286+
Command::new("git")
2287+
.current_dir(&dir)
2288+
.env("GIT_AUTHOR_NAME", "T")
2289+
.env("GIT_AUTHOR_EMAIL", "t@t")
2290+
.env("GIT_COMMITTER_NAME", "T")
2291+
.env("GIT_COMMITTER_EMAIL", "t@t")
2292+
.args(args)
2293+
.output()
2294+
.unwrap()
2295+
};
2296+
git(&["init", "-q"]);
2297+
std::fs::write(dir.join("a.txt"), "v1").unwrap();
2298+
git(&["add", "."]);
2299+
git(&["commit", "-q", "-m", "init"]);
2300+
let cwd = dir.to_string_lossy().to_string();
2301+
// Snapshot the "v1" state.
2302+
let cp0 = super::git_checkpoint_capture(cwd.clone()).unwrap();
2303+
assert!(!cp0.is_empty());
2304+
// The agent modifies a tracked file and adds a new one.
2305+
std::fs::write(dir.join("a.txt"), "v2").unwrap();
2306+
std::fs::write(dir.join("b.txt"), "new").unwrap();
2307+
// Restore to the checkpoint: a.txt reverts, current state saved as safety.
2308+
let safety = super::git_checkpoint_restore(cwd.clone(), cp0).unwrap();
2309+
assert!(!safety.is_empty());
2310+
assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "v1");
2311+
// Files created after the checkpoint are left in place (never deleted).
2312+
assert!(dir.join("b.txt").exists());
2313+
// A bogus sha is rejected without touching the tree.
2314+
assert!(super::git_checkpoint_restore(cwd, "nothex!!".into()).is_err());
2315+
let _ = std::fs::remove_dir_all(&dir);
2316+
}
2317+
22022318
#[test]
22032319
fn empty_file_is_empty_object() {
22042320
let p = tmp("empty.json");

src/lib/chat.svelte.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ export class ChatState {
110110
// instantly from cache while a fresh `/model` round-trip refreshes it.
111111
modelCatalog = $state<ModelOption[]>([]);
112112
modelCatalogEffort = $state('');
113+
// File-checkpoint sha captured just before each user turn (index = turn), so a
114+
// rewind can restore the working tree to that turn's state (codex/claude, where
115+
// the engine only rewinds the conversation, not files).
116+
fileCheckpoints = $state<Record<number, string>>({});
113117
contextTokens = $state(0);
114118
contextWindow = $state(0);
115119
contextLimit = $state(0);

src/lib/protocol.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,18 @@ export function transcribeAudio(audioBase64: string, mime?: string, language?: s
318318
return invoke('transcribe_audio', { audioBase64, mime, language });
319319
}
320320

321+
/** Snapshot the working tree as a dangling checkpoint commit (returns its sha).
322+
* Non-destructive: it never touches the index or working tree. */
323+
export function gitCheckpointCapture(cwd: string): Promise<string> {
324+
return invoke('git_checkpoint_capture', { cwd });
325+
}
326+
327+
/** Restore the working tree to a checkpoint sha. Snapshots the current state
328+
* first (returns that recovery sha) so nothing is unrecoverable. */
329+
export function gitCheckpointRestore(cwd: string, checkpoint: string): Promise<string> {
330+
return invoke('git_checkpoint_restore', { cwd, checkpoint });
331+
}
332+
321333
/** One-shot LLM completion (no agent / no chat pollution) — powers AI commit
322334
* messages and PR text. The key is read engine-side from auth.json by provider. */
323335
export function generateText(

src/routes/+page.svelte

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
stopScreenRecording,
2424
processVideo,
2525
claudeSessions,
26+
gitCheckpointCapture,
27+
gitCheckpointRestore,
2628
type EventPayload,
2729
type Op
2830
} from '$lib/protocol';
@@ -373,7 +375,10 @@
373375
// engine. Returns false when there's no live session to receive it.
374376
function sendAiEdit(content: string): boolean {
375377
if (!chat || chat.engineState === 'exited') return false;
376-
if (!chat.busy) chat.optimisticUser(content);
378+
if (!chat.busy) {
379+
captureCheckpoint();
380+
chat.optimisticUser(content);
381+
}
377382
send({ op: 'user_message', content });
378383
return true;
379384
}
@@ -777,7 +782,10 @@
777782
}
778783
// Echo the message instantly when it starts a turn now (a busy session
779784
// queues it instead, shown in the composer's queue strip).
780-
if (chat && !chat.busy) chat.optimisticUser(content);
785+
if (chat && !chat.busy) {
786+
captureCheckpoint(); // snapshot files before this turn (for rewind)
787+
chat.optimisticUser(content);
788+
}
781789
send({ op: 'user_message', content, images: images.length ? images : undefined });
782790
}
783791
input = '';
@@ -980,6 +988,28 @@
980988
// Real edit-and-resend: rewind the conversation (and files) to the turn that
981989
// produced this user message, then drop its text back into the composer. The
982990
// engine lists user turns in order, so the i-th turn matches the i-th message.
991+
// Before each codex/claude turn, snapshot the working tree so a later rewind
992+
// can restore files to this turn's starting state (the engines rewind only the
993+
// conversation). Fire-and-forget; the sha lands under its turn index.
994+
function captureCheckpoint() {
995+
const c = chat;
996+
const cwd = activeProject?.path;
997+
if (!c || !cwd || (c.backendId !== 'codex' && c.backendId !== 'claude')) return;
998+
const idx = c.userTurns;
999+
gitCheckpointCapture(cwd)
1000+
.then((sha) => {
1001+
if (sha) c.fileCheckpoints[idx] = sha;
1002+
})
1003+
.catch(() => {});
1004+
}
1005+
// Restore the working tree to the checkpoint captured before the target turn.
1006+
function restoreCheckpoint(userIndex: number) {
1007+
const c = chat;
1008+
const cwd = activeProject?.path;
1009+
const sha = c?.fileCheckpoints[userIndex];
1010+
if (c && cwd && sha) gitCheckpointRestore(cwd, sha).catch((e) => console.error('checkpoint restore failed', e));
1011+
}
1012+
9831013
function rewindToMessage(text: string, userIndex: number) {
9841014
if (!chat) return;
9851015
// codex (thread/rollback) and claude (resume-at-uuid respawn) rewind without
@@ -1000,10 +1030,12 @@
10001030
if (numTurns > 0) send({ op: 'command', input: `/rewind ${numTurns}` });
10011031
// codex rolls back its own history; mirror it in our projected transcript.
10021032
chat.truncateToUserTurn(userIndex);
1033+
restoreCheckpoint(userIndex); // …and the files it changed
10031034
} else if (pr.id.startsWith('claude:')) {
10041035
const userIndex = Number(pr.id.slice('claude:'.length));
10051036
// Respawn resuming at the previous turn's assistant uuid (or fresh at 0).
10061037
store.rewindClaudeSession(activeId, chat.claudeRewindTarget(userIndex), userIndex);
1038+
restoreCheckpoint(userIndex);
10071039
} else {
10081040
send({ op: 'command', input: `/rewind ${pr.id}` });
10091041
}

0 commit comments

Comments
 (0)