@@ -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).
17351810fn 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" ) ;
0 commit comments