11import * as childProcess from "child_process" ;
2+ import * as crypto from "crypto" ;
23import * as fs from "fs" ;
34import * as path from "path" ;
45
56const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint" ;
67const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost" ;
8+ const MANIFEST_PATH = ".deepcode-file-history.json" ;
9+
10+ type FileHistoryEntry = {
11+ path : string ;
12+ blob : string ;
13+ mode : "100644" ;
14+ } ;
15+
16+ type FileHistoryManifest = {
17+ version : 1 ;
18+ files : Record < string , FileHistoryEntry > ;
19+ } ;
720
821export class GitFileHistory {
922 constructor (
10- private readonly projectRoot : string ,
23+ _projectRoot : string ,
1124 private readonly gitDir : string
1225 ) { }
1326
@@ -20,17 +33,17 @@ export class GitFileHistory {
2033 try {
2134 if ( ! fs . existsSync ( this . gitDir ) ) {
2235 fs . mkdirSync ( path . dirname ( this . gitDir ) , { recursive : true } ) ;
23- this . runGit ( [ "init" ] , { includeWorkTree : true } ) ;
36+ this . runGit ( [ "init" ] ) ;
2437 }
2538
2639 const current = this . getCurrentCheckpointHash ( sessionId ) ;
2740 if ( current ) {
2841 return current ;
2942 }
3043
31- const emptyTree = this . runGit ( [ "mktree" ] , { includeWorkTree : false , input : "" } ) . trim ( ) ;
32- const commitHash = this . createCommit ( emptyTree , null , "Initial checkpoint" ) ;
33- this . runGit ( [ "update-ref" , branchRef , commitHash ] , { includeWorkTree : false } ) ;
44+ const treeHash = this . createTree ( emptyManifest ( ) ) ;
45+ const commitHash = this . createCommit ( treeHash , null , "Initial checkpoint" ) ;
46+ this . runGit ( [ "update-ref" , branchRef , commitHash ] ) ;
3447 return commitHash ;
3548 } catch {
3649 return undefined ;
@@ -44,9 +57,7 @@ export class GitFileHistory {
4457 }
4558
4659 try {
47- const hash = this . runGit ( [ "rev-parse" , "--verify" , `${ branchRef } ^{commit}` ] , {
48- includeWorkTree : false ,
49- } ) . trim ( ) ;
60+ const hash = this . runGit ( [ "rev-parse" , "--verify" , `${ branchRef } ^{commit}` ] ) . trim ( ) ;
5061 return isCommitHash ( hash ) ? hash : undefined ;
5162 } catch {
5263 return undefined ;
@@ -59,10 +70,8 @@ export class GitFileHistory {
5970 return undefined ;
6071 }
6172
62- const relativePaths = filePaths
63- . map ( ( filePath ) => this . toProjectRelativeGitPath ( filePath ) )
64- . filter ( ( filePath ) : filePath is string => Boolean ( filePath ) ) ;
65- if ( relativePaths . length === 0 ) {
73+ const absolutePaths = uniqueAbsolutePaths ( filePaths ) ;
74+ if ( absolutePaths . length === 0 ) {
6675 return this . getCurrentCheckpointHash ( sessionId ) ;
6776 }
6877
@@ -71,18 +80,30 @@ export class GitFileHistory {
7180 if ( ! parentHash ) {
7281 return undefined ;
7382 }
74- this . runGit ( [ "read-tree" , "--reset" , branchRef ] , { includeWorkTree : true } ) ;
75- this . runGit ( [ "add" , "-f" , "-A" , "--" , ...relativePaths ] , { includeWorkTree : true } ) ;
76- const treeHash = this . runGit ( [ "write-tree" ] , { includeWorkTree : false } ) . trim ( ) ;
77- const parentTreeHash = this . runGit ( [ "rev-parse" , `${ parentHash } ^{tree}` ] , {
78- includeWorkTree : false ,
79- } ) . trim ( ) ;
83+
84+ const manifest = this . readManifest ( parentHash ) ;
85+ for ( const filePath of absolutePaths ) {
86+ const key = this . getFileKey ( filePath ) ;
87+ if ( ! fs . existsSync ( filePath ) || ! fs . statSync ( filePath ) . isFile ( ) ) {
88+ delete manifest . files [ key ] ;
89+ continue ;
90+ }
91+
92+ manifest . files [ key ] = {
93+ path : filePath ,
94+ blob : this . hashFile ( filePath ) ,
95+ mode : "100644" ,
96+ } ;
97+ }
98+
99+ const treeHash = this . createTree ( manifest ) ;
100+ const parentTreeHash = this . runGit ( [ "rev-parse" , `${ parentHash } ^{tree}` ] ) . trim ( ) ;
80101 if ( treeHash === parentTreeHash ) {
81102 return parentHash ;
82103 }
83104
84105 const commitHash = this . createCommit ( treeHash , parentHash , message ) ;
85- this . runGit ( [ "update-ref" , branchRef , commitHash , parentHash ] , { includeWorkTree : false } ) ;
106+ this . runGit ( [ "update-ref" , branchRef , commitHash , parentHash ] ) ;
86107 return commitHash ;
87108 } catch {
88109 return undefined ;
@@ -101,7 +122,8 @@ export class GitFileHistory {
101122 }
102123
103124 try {
104- this . runGit ( [ "cat-file" , "-e" , `${ checkpointHash } ^{commit}` ] , { includeWorkTree : false } ) ;
125+ this . runGit ( [ "cat-file" , "-e" , `${ checkpointHash } ^{commit}` ] ) ;
126+ this . readManifest ( checkpointHash ) ;
105127 return true ;
106128 } catch {
107129 return false ;
@@ -116,16 +138,24 @@ export class GitFileHistory {
116138 if ( ! branchRef || ! fs . existsSync ( this . gitDir ) ) {
117139 throw new Error ( "File history Git repository was not found for this project." ) ;
118140 }
119- this . runGit ( [ "cat-file" , "-e" , `${ checkpointHash } ^{commit}` ] , { includeWorkTree : false } ) ;
141+ this . runGit ( [ "cat-file" , "-e" , `${ checkpointHash } ^{commit}` ] ) ;
120142
121- try {
122- this . runGit ( [ "read-tree" , "--reset" , branchRef ] , { includeWorkTree : true } ) ;
123- } catch {
124- // If the session branch is missing, fall back to the target tree only.
125- // The target checkpoint has already been validated above.
143+ const currentHash = this . getCurrentCheckpointHash ( sessionId ) ;
144+ const currentManifest = currentHash ? this . readManifest ( currentHash ) : emptyManifest ( ) ;
145+ const targetManifest = this . readManifest ( checkpointHash ) ;
146+
147+ for ( const [ key , entry ] of Object . entries ( currentManifest . files ) ) {
148+ if ( ! targetManifest . files [ key ] ) {
149+ removeTrackedFile ( entry . path ) ;
150+ }
126151 }
127- this . runGit ( [ "read-tree" , "--reset" , "-u" , checkpointHash ] , { includeWorkTree : true } ) ;
128- this . runGit ( [ "update-ref" , branchRef , checkpointHash ] , { includeWorkTree : false } ) ;
152+
153+ for ( const entry of Object . values ( targetManifest . files ) ) {
154+ fs . mkdirSync ( path . dirname ( entry . path ) , { recursive : true } ) ;
155+ fs . writeFileSync ( entry . path , this . readBlob ( entry . blob ) ) ;
156+ }
157+
158+ this . runGit ( [ "update-ref" , branchRef , checkpointHash ] ) ;
129159 }
130160
131161 private getSessionBranchRef ( sessionId : string ) : string | null {
@@ -142,41 +172,125 @@ export class GitFileHistory {
142172 }
143173 args . push ( "-m" , message ) ;
144174 return this . runGit ( args , {
145- includeWorkTree : false ,
146175 env : getFileHistoryGitEnv ( ) ,
147176 } ) . trim ( ) ;
148177 }
149178
150- private toProjectRelativeGitPath ( filePath : string ) : string | null {
151- const absolutePath = path . resolve ( filePath ) ;
152- const relativePath = path . relative ( this . projectRoot , absolutePath ) ;
153- if ( ! relativePath || relativePath . startsWith ( ".." ) || path . isAbsolute ( relativePath ) ) {
154- return null ;
179+ private createTree ( manifest : FileHistoryManifest ) : string {
180+ const normalizedManifest = normalizeManifest ( manifest ) ;
181+ const manifestBlob = this . hashContent ( `${ JSON . stringify ( normalizedManifest , null , 2 ) } \n` ) ;
182+ const entries : string [ ] = [ `100644 blob ${ manifestBlob } \t${ MANIFEST_PATH } \0` ] ;
183+
184+ for ( const [ key , entry ] of Object . entries ( normalizedManifest . files ) ) {
185+ entries . push ( `${ entry . mode } blob ${ entry . blob } \t${ key } \0` ) ;
155186 }
156- return relativePath . split ( path . sep ) . join ( "/" ) ;
187+
188+ return this . runGit ( [ "mktree" , "-z" ] , { input : entries . join ( "" ) } ) . trim ( ) ;
157189 }
158190
159- private runGit (
160- args : string [ ] ,
161- options : { includeWorkTree : boolean ; input ?: string ; env ?: NodeJS . ProcessEnv }
162- ) : string {
163- const gitArgs = [ "-c" , "core.autocrlf=false" , "-c" , "core.eol=lf" , `--git-dir=${ this . gitDir } ` ] ;
164- if ( options . includeWorkTree ) {
165- gitArgs . push ( `--work-tree=${ this . projectRoot } ` ) ;
191+ private readManifest ( commitHash : string ) : FileHistoryManifest {
192+ const buffer = this . runGitBuffer ( [ "cat-file" , "blob" , `${ commitHash } :${ MANIFEST_PATH } ` ] ) ;
193+ const parsed = JSON . parse ( buffer . toString ( "utf8" ) ) as FileHistoryManifest ;
194+ if ( ! parsed || parsed . version !== 1 || ! parsed . files || typeof parsed . files !== "object" ) {
195+ throw new Error ( "Invalid file history manifest." ) ;
196+ }
197+ return normalizeManifest ( parsed ) ;
198+ }
199+
200+ private readBlob ( blobHash : string ) : Buffer {
201+ if ( ! isCommitHash ( blobHash ) ) {
202+ throw new Error ( "Invalid file history blob hash." ) ;
203+ }
204+ return this . runGitBuffer ( [ "cat-file" , "blob" , blobHash ] ) ;
205+ }
206+
207+ private hashFile ( filePath : string ) : string {
208+ const blobHash = this . runGit ( [ "hash-object" , "-w" , "--" , filePath ] ) . trim ( ) ;
209+ if ( ! isCommitHash ( blobHash ) ) {
210+ throw new Error ( "Invalid file history blob hash." ) ;
166211 }
167- gitArgs . push ( ...args ) ;
212+ return blobHash ;
213+ }
214+
215+ private hashContent ( content : string ) : string {
216+ const blobHash = this . runGit ( [ "hash-object" , "-w" , "--stdin" ] , { input : content } ) . trim ( ) ;
217+ if ( ! isCommitHash ( blobHash ) ) {
218+ throw new Error ( "Invalid file history blob hash." ) ;
219+ }
220+ return blobHash ;
221+ }
222+
223+ private getFileKey ( filePath : string ) : string {
224+ const hash = crypto . createHash ( "sha256" ) . update ( filePath ) . digest ( "hex" ) ;
225+ return `files-${ hash } ` ;
226+ }
227+
228+ private runGit ( args : string [ ] , options : { input ?: string | Buffer ; env ?: NodeJS . ProcessEnv } = { } ) : string {
229+ return this . spawnGit ( args , options , "utf8" ) as string ;
230+ }
231+
232+ private runGitBuffer ( args : string [ ] , options : { input ?: string | Buffer ; env ?: NodeJS . ProcessEnv } = { } ) : Buffer {
233+ return this . spawnGit ( args , options , "buffer" ) as Buffer ;
234+ }
235+
236+ private spawnGit (
237+ args : string [ ] ,
238+ options : { input ?: string | Buffer ; env ?: NodeJS . ProcessEnv } ,
239+ encoding : BufferEncoding | "buffer"
240+ ) : string | Buffer {
241+ const gitArgs = [ "-c" , "core.autocrlf=false" , "-c" , "core.eol=lf" , `--git-dir=${ this . gitDir } ` , ...args ] ;
168242 const result = childProcess . spawnSync ( "git" , gitArgs , {
169- encoding : "utf8" ,
243+ encoding,
170244 input : options . input ,
171245 env : options . env ,
172246 stdio : [ "pipe" , "pipe" , "pipe" ] ,
173247 } ) ;
174248 if ( result . status !== 0 ) {
175- const detail = ( result . stderr || result . stdout || "" ) . trim ( ) ;
249+ const stderr = Buffer . isBuffer ( result . stderr ) ? result . stderr . toString ( "utf8" ) : result . stderr ;
250+ const stdout = Buffer . isBuffer ( result . stdout ) ? result . stdout . toString ( "utf8" ) : result . stdout ;
251+ const detail = ( stderr || stdout || "" ) . trim ( ) ;
176252 throw new Error ( detail || `git ${ args . join ( " " ) } failed` ) ;
177253 }
178- return result . stdout ?? "" ;
254+ return result . stdout ?? ( encoding === "buffer" ? Buffer . alloc ( 0 ) : "" ) ;
255+ }
256+ }
257+
258+ function emptyManifest ( ) : FileHistoryManifest {
259+ return { version : 1 , files : { } } ;
260+ }
261+
262+ function normalizeManifest ( manifest : FileHistoryManifest ) : FileHistoryManifest {
263+ const files : Record < string , FileHistoryEntry > = { } ;
264+ for ( const [ key , entry ] of Object . entries ( manifest . files ) . sort ( ( [ left ] , [ right ] ) => left . localeCompare ( right ) ) ) {
265+ if ( ! isValidStoredPath ( key ) || ! entry || entry . mode !== "100644" || ! isCommitHash ( entry . blob ) ) {
266+ throw new Error ( "Invalid file history manifest." ) ;
267+ }
268+ files [ key ] = {
269+ path : path . resolve ( entry . path ) ,
270+ blob : entry . blob ,
271+ mode : "100644" ,
272+ } ;
273+ }
274+ return { version : 1 , files } ;
275+ }
276+
277+ function uniqueAbsolutePaths ( filePaths : string [ ] ) : string [ ] {
278+ return Array . from ( new Set ( filePaths . map ( ( filePath ) => path . resolve ( filePath ) ) ) ) ;
279+ }
280+
281+ function isValidStoredPath ( value : string ) : boolean {
282+ return / ^ f i l e s - [ 0 - 9 a - f ] { 64 } $ / . test ( value ) ;
283+ }
284+
285+ function removeTrackedFile ( filePath : string ) : void {
286+ if ( ! fs . existsSync ( filePath ) ) {
287+ return ;
288+ }
289+ const stat = fs . lstatSync ( filePath ) ;
290+ if ( stat . isDirectory ( ) ) {
291+ return ;
179292 }
293+ fs . unlinkSync ( filePath ) ;
180294}
181295
182296function getFileHistoryGitEnv ( ) : NodeJS . ProcessEnv {
0 commit comments