@@ -4,6 +4,7 @@ import * as NodePath from "node:path";
44import * as NodeServices from "@effect/platform-node/NodeServices" ;
55import { it } from "@effect/vitest" ;
66import { ThreadId , type VcsError } from "@t3tools/contracts" ;
7+ import { HostProcessPlatform } from "@t3tools/shared/hostProcess" ;
78import * as Effect from "effect/Effect" ;
89import * as FileSystem from "effect/FileSystem" ;
910import * as Layer from "effect/Layer" ;
@@ -12,6 +13,7 @@ import * as Scope from "effect/Scope";
1213import { describe , expect } from "vite-plus/test" ;
1314
1415import { checkpointRefForThreadTurn } from "./Utils.ts" ;
16+ import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts" ;
1517import * as CheckpointStore from "./CheckpointStore.ts" ;
1618import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts" ;
1719import * as VcsProcess from "../vcs/VcsProcess.ts" ;
@@ -250,6 +252,182 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => {
250252 expect ( whitespaceIgnoredDiff ) . toContain ( "+ <div>" ) ;
251253 expect ( whitespaceIgnoredDiff ) . not . toContain ( "- <h1>Title</h1>" ) ;
252254 expect ( whitespaceIgnoredDiff ) . not . toContain ( "+ <h1>Title</h1>" ) ;
255+
256+ for ( const ignoreWhitespace of [ false , true ] ) {
257+ const numstat = yield * checkpointStore . diffCheckpoints ( {
258+ cwd : tmp ,
259+ fromCheckpointRef,
260+ toCheckpointRef,
261+ ignoreWhitespace,
262+ format : "numstat" ,
263+ } ) ;
264+ expect ( parseTurnDiffFilesFromNumstat ( numstat ) ) . toEqual ( [
265+ {
266+ path : "Component.tsx" ,
267+ additions : ignoreWhitespace ? 4 : 6 ,
268+ deletions : ignoreWhitespace ? 0 : 2 ,
269+ } ,
270+ ] ) ;
271+ }
272+ } ) ,
273+ ) ;
274+ } ) ;
275+
276+ describe ( "checkpoint file summaries" , ( ) => {
277+ it . effect ( "counts changes whose full patch exceeds the output limit" , ( ) =>
278+ Effect . gen ( function * ( ) {
279+ const tmp = yield * makeTmpDir ( ) ;
280+ yield * initRepoWithCommit ( tmp ) ;
281+ const checkpointStore = yield * CheckpointStore . CheckpointStore ;
282+ const threadId = ThreadId . make ( "large-checkpoint-summary" ) ;
283+ const fromCheckpointRef = checkpointRefForThreadTurn ( threadId , 0 ) ;
284+ const toCheckpointRef = checkpointRefForThreadTurn ( threadId , 1 ) ;
285+ const filePath = NodePath . join ( tmp , "README.md" ) ;
286+ const lineCount = 20_000 ;
287+ yield * writeTextFile ( filePath , `${ "before" . repeat ( 50 ) } \n` . repeat ( lineCount ) ) ;
288+ yield * checkpointStore . captureCheckpoint ( { cwd : tmp , checkpointRef : fromCheckpointRef } ) ;
289+ yield * writeTextFile ( filePath , `${ "after" . repeat ( 60 ) } \n` . repeat ( lineCount ) ) ;
290+ yield * checkpointStore . captureCheckpoint ( { cwd : tmp , checkpointRef : toCheckpointRef } ) ;
291+
292+ const numstat = yield * checkpointStore . diffCheckpoints ( {
293+ cwd : tmp ,
294+ fromCheckpointRef,
295+ toCheckpointRef,
296+ ignoreWhitespace : false ,
297+ format : "numstat" ,
298+ } ) ;
299+
300+ expect ( parseTurnDiffFilesFromNumstat ( numstat ) ) . toEqual ( [
301+ { path : "README.md" , additions : lineCount , deletions : lineCount } ,
302+ ] ) ;
303+ expect ( numstat . length ) . toBeLessThan ( 100 ) ;
304+ } ) ,
305+ ) ;
306+
307+ it . effect ( "preserves file paths and turn ranges without changing the user index" , ( ) =>
308+ Effect . gen ( function * ( ) {
309+ const tmp = yield * makeTmpDir ( ) ;
310+ yield * initRepoWithCommit ( tmp ) ;
311+ yield * git ( tmp , [ "config" , "diff.renames" , "copies" ] ) ;
312+ const fileSystem = yield * FileSystem . FileSystem ;
313+ const checkpointStore = yield * CheckpointStore . CheckpointStore ;
314+ const threadId = ThreadId . make ( "checkpoint-summary-paths" ) ;
315+ const baseline = checkpointRefForThreadTurn ( threadId , 0 ) ;
316+ const firstTurn = checkpointRefForThreadTurn ( threadId , 1 ) ;
317+ const secondTurn = checkpointRefForThreadTurn ( threadId , 2 ) ;
318+ const copiedText = Array . from ( { length : 20 } , ( _ , index ) => `copy line ${ index } \n` ) . join (
319+ "" ,
320+ ) ;
321+ const platform = yield * HostProcessPlatform ;
322+ const renamedPath = platform === "win32" ? "renamed café.txt" : "renamed\tcafé\nname.txt" ;
323+ const addedPath = platform === "win32" ? "new café.txt" : "new\tfile\n名.txt" ;
324+ for ( const [ path , contents ] of Object . entries ( {
325+ "copy-source.txt" : copiedText ,
326+ "deleted.txt" : "delete me\n" ,
327+ "rename-old.txt" : "before\nkeep one\nkeep two\nkeep three\n" ,
328+ "binary.bin" : "\0before" ,
329+ } ) ) {
330+ yield * writeTextFile ( NodePath . join ( tmp , path ) , contents ) ;
331+ }
332+ yield * checkpointStore . captureCheckpoint ( { cwd : tmp , checkpointRef : baseline } ) ;
333+
334+ yield * fileSystem . rename (
335+ NodePath . join ( tmp , "rename-old.txt" ) ,
336+ NodePath . join ( tmp , renamedPath ) ,
337+ ) ;
338+ yield * fileSystem . remove ( NodePath . join ( tmp , "deleted.txt" ) ) ;
339+ for ( const [ path , contents ] of Object . entries ( {
340+ "copy-source.txt" : `${ copiedText } one more\n` ,
341+ "copied.txt" : copiedText ,
342+ [ renamedPath ] : "after\nkeep one\nkeep two\nkeep three\n" ,
343+ "binary.bin" : "\0after" ,
344+ "empty.txt" : "" ,
345+ [ addedPath ] : "first\nsecond\n" ,
346+ } ) ) {
347+ yield * writeTextFile ( NodePath . join ( tmp , path ) , contents ) ;
348+ }
349+ yield * checkpointStore . captureCheckpoint ( { cwd : tmp , checkpointRef : firstTurn } ) ;
350+ const userIndex = yield * fileSystem . readFile ( NodePath . join ( tmp , ".git/index" ) ) ;
351+ const input = {
352+ cwd : tmp ,
353+ fromCheckpointRef : baseline ,
354+ toCheckpointRef : firstTurn ,
355+ ignoreWhitespace : false ,
356+ format : "numstat" as const ,
357+ } ;
358+ const firstSummary = parseTurnDiffFilesFromNumstat (
359+ yield * checkpointStore . diffCheckpoints ( input ) ,
360+ ) ;
361+ const expectedFiles = [
362+ { path : "binary.bin" , additions : 0 , deletions : 0 } ,
363+ { path : "copied.txt" , additions : 0 , deletions : 0 } ,
364+ { path : "copy-source.txt" , additions : 1 , deletions : 0 } ,
365+ { path : "deleted.txt" , additions : 0 , deletions : 1 } ,
366+ { path : "empty.txt" , additions : 0 , deletions : 0 } ,
367+ { path : addedPath , additions : 2 , deletions : 0 } ,
368+ { path : renamedPath , additions : 1 , deletions : 1 } ,
369+ ] . toSorted ( ( left , right ) => left . path . localeCompare ( right . path ) ) ;
370+ expect ( firstSummary ) . toEqual ( expectedFiles ) ;
371+
372+ yield * fileSystem . remove ( NodePath . join ( tmp , "empty.txt" ) ) ;
373+ yield * writeTextFile ( NodePath . join ( tmp , "copy-source.txt" ) , "replacement\n" ) ;
374+ yield * checkpointStore . captureCheckpoint ( { cwd : tmp , checkpointRef : secondTurn } ) ;
375+ const secondSummary = parseTurnDiffFilesFromNumstat (
376+ yield * checkpointStore . diffCheckpoints ( {
377+ ...input ,
378+ fromCheckpointRef : firstTurn ,
379+ toCheckpointRef : secondTurn ,
380+ } ) ,
381+ ) ;
382+ expect ( secondSummary ) . toEqual ( [
383+ { path : "copy-source.txt" , additions : 1 , deletions : 21 } ,
384+ { path : "empty.txt" , additions : 0 , deletions : 0 } ,
385+ ] ) ;
386+
387+ const inclusiveSummary = parseTurnDiffFilesFromNumstat (
388+ yield * checkpointStore . diffCheckpoints ( { ...input , toCheckpointRef : secondTurn } ) ,
389+ ) ;
390+ expect ( inclusiveSummary ) . toEqual (
391+ expectedFiles
392+ . filter ( ( file ) => file . path !== "empty.txt" )
393+ . map ( ( file ) =>
394+ file . path === "copy-source.txt" ? { ...file , additions : 1 , deletions : 20 } : file ,
395+ ) ,
396+ ) ;
397+ expect (
398+ yield * checkpointStore . diffCheckpoints ( { ...input , toCheckpointRef : baseline } ) ,
399+ ) . toBe ( "" ) ;
400+ expect ( yield * fileSystem . readFile ( NodePath . join ( tmp , ".git/index" ) ) ) . toEqual ( userIndex ) ;
401+ } ) ,
402+ ) ;
403+
404+ it . effect ( "uses HEAD for a missing baseline only when requested" , ( ) =>
405+ Effect . gen ( function * ( ) {
406+ const tmp = yield * makeTmpDir ( ) ;
407+ yield * initRepoWithCommit ( tmp ) ;
408+ const checkpointStore = yield * CheckpointStore . CheckpointStore ;
409+ const threadId = ThreadId . make ( "checkpoint-summary-fallback" ) ;
410+ const fromCheckpointRef = checkpointRefForThreadTurn ( threadId , 0 ) ;
411+ const toCheckpointRef = checkpointRefForThreadTurn ( threadId , 1 ) ;
412+ yield * writeTextFile ( NodePath . join ( tmp , "README.md" ) , "changed\n" ) ;
413+ yield * checkpointStore . captureCheckpoint ( { cwd : tmp , checkpointRef : toCheckpointRef } ) ;
414+ const input = {
415+ cwd : tmp ,
416+ fromCheckpointRef,
417+ toCheckpointRef,
418+ ignoreWhitespace : false ,
419+ format : "numstat" as const ,
420+ } ;
421+
422+ const error = yield * Effect . flip ( checkpointStore . diffCheckpoints ( input ) ) ;
423+ expect ( error . _tag ) . toBe ( "VcsProcessExitError" ) ;
424+ const numstat = yield * checkpointStore . diffCheckpoints ( {
425+ ...input ,
426+ fallbackFromToHead : true ,
427+ } ) ;
428+ expect ( parseTurnDiffFilesFromNumstat ( numstat ) ) . toEqual ( [
429+ { path : "README.md" , additions : 1 , deletions : 1 } ,
430+ ] ) ;
253431 } ) ,
254432 ) ;
255433 } ) ;
0 commit comments