11import { randomUUID } from "node:crypto" ;
2- import { existsSync , mkdirSync , readFileSync , writeFileSync } from "node:fs" ;
2+ import { existsSync , mkdirSync , readFileSync , statSync , writeFileSync } from "node:fs" ;
33import { join , resolve } from "node:path" ;
4+ import { replaceFileAtomically } from "../helpers/atomicFile.js" ;
45
56export const ID_PATH = join ( ".hyperframes" , "history-id" ) ;
67/** The only shape minted here; the id is project content and becomes a path, so nothing else is trusted. */
78const ID_SHAPE = / ^ [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } $ / ;
89
9- function readId ( projectDir : string ) : string | null {
10+ export function readId ( projectDir : string ) : string | null {
1011 try {
1112 const id = readFileSync ( join ( projectDir , ID_PATH ) , "utf-8" ) . trim ( ) ;
1213 return ID_SHAPE . test ( id ) ? id : null ;
@@ -15,28 +16,42 @@ function readId(projectDir: string): string | null {
1516 }
1617}
1718
18- function recordedDir ( historyDir : string ) : string | null {
19+ export type FolderIdentity = { ino : number ; birthtimeMs : number } ;
20+
21+ export const sameFolder = ( a : FolderIdentity , b : FolderIdentity ) =>
22+ a . ino === b . ino && a . birthtimeMs === b . birthtimeMs ;
23+
24+ export function isRecordedFolder ( historyDir : string , folder : FolderIdentity ) : boolean {
1925 try {
20- return JSON . parse ( readFileSync ( join ( historyDir , "project.json" ) , "utf-8" ) ) . dir ?? null ;
26+ const was = JSON . parse ( readFileSync ( join ( historyDir , "project.json" ) , "utf-8" ) ) ;
27+ return sameFolder ( { ino : was . ino , birthtimeMs : was . born } , folder ) ;
2128 } catch {
22- return null ;
29+ return false ;
2330 }
2431}
2532
2633/**
27- * The project's history id, kept in the project so a rename or move keeps its history. A folder whose id is
28- * still carried by the folder that history was recorded for is a copy, and gets an id of its own .
34+ * The project's history id, kept in the project so a rename or move keeps its history. Where history exists under it,
35+ * only the recorded folder keeps it: same inode and creation time, since an inode alone is reused after a delete .
2936 */
3037export function projectHistoryId ( projectDir : string , historyRoot : string ) : string {
3138 const dir = resolve ( projectDir ) ;
39+ const folder = statSync ( dir ) ;
3240 let id = readId ( dir ) ;
33- const was = id && recordedDir ( join ( historyRoot , id ) ) ;
34- if ( ! id || ( was && was !== dir && existsSync ( was ) && readId ( was ) === id ) ) {
41+ if (
42+ ! id ||
43+ ( existsSync ( join ( historyRoot , id ) ) && ! isRecordedFolder ( join ( historyRoot , id ) , folder ) )
44+ ) {
3545 id = randomUUID ( ) ;
3646 mkdirSync ( join ( dir , ".hyperframes" ) , { recursive : true } ) ;
3747 writeFileSync ( join ( dir , ID_PATH ) , `${ id } \n` ) ;
3848 }
39- mkdirSync ( join ( historyRoot , id ) , { recursive : true } ) ;
40- writeFileSync ( join ( historyRoot , id , "project.json" ) , JSON . stringify ( { dir } ) ) ;
49+ recordProject ( join ( historyRoot , id ) , dir , folder ) ;
4150 return id ;
4251}
52+
53+ export function recordProject ( historyDir : string , dir : string , folder : FolderIdentity ) : void {
54+ const record = { dir, ino : folder . ino , born : folder . birthtimeMs } ;
55+ mkdirSync ( historyDir , { recursive : true } ) ;
56+ replaceFileAtomically ( join ( historyDir , "project.json" ) , JSON . stringify ( record ) , 0o644 ) ;
57+ }
0 commit comments