1010
1111import { Command } from 'commander' ;
1212import chalk from 'chalk' ;
13- import { existsSync , writeFileSync , copyFileSync , mkdirSync , readdirSync , statSync , renameSync , unlinkSync } from 'fs' ;
13+ import { existsSync , readFileSync , writeFileSync , copyFileSync , mkdirSync , readdirSync , statSync , renameSync , unlinkSync } from 'fs' ;
1414import { join , relative , basename } from 'path' ;
1515import { formatBytes } from '../../utils/helpers.js' ;
1616import { safePath , isSafeFilename } from '../../utils/security.js' ;
@@ -115,14 +115,24 @@ const stageCommand = new Command('stage')
115115 }
116116 } ) ;
117117
118+ /**
119+ * Staged files are stored flat (subdirectory separators become "__") so the
120+ * staging dir stays a single level. The original relative path is recorded in
121+ * the staging index so commit can name archive entries correctly — otherwise
122+ * a rollback would restore "data__sample.txt" instead of "data/sample.txt".
123+ */
124+ function flattenName ( relPath ) {
125+ return relPath . replace ( / [ \\ / ] / g, '__' ) ;
126+ }
127+
118128function stageFile ( filePath , stagedFiles , stagingDir , verbose , rejectedFiles , originalArg ) {
119129 try {
120- const relPath = originalArg || relative ( process . cwd ( ) , filePath ) ;
121- const safeName = relPath . replace ( / [ \\ / ] / g , '__' ) ;
130+ const relPath = ( originalArg || relative ( process . cwd ( ) , filePath ) ) . replace ( / \\ / g , '/' ) ;
131+ const safeName = flattenName ( relPath ) ;
122132 const stagedPath = join ( stagingDir , safeName ) ;
123133
124134 copyFileSync ( filePath , stagedPath ) ;
125- stagedFiles . push ( relPath ) ;
135+ stagedFiles . push ( { staged : safeName , path : relPath } ) ;
126136
127137 if ( verbose ) {
128138 console . log ( chalk . green ( ` + ${ relPath } ` ) ) ;
@@ -134,20 +144,34 @@ function stageFile(filePath, stagedFiles, stagingDir, verbose, rejectedFiles, or
134144}
135145
136146function showStagedFiles ( stagingDir , verbose ) {
137- const files = readdirSync ( stagingDir ) . filter ( f => f !== 'index.json' ) ;
147+ // Prefer the index mapping (original paths) when present; fall back to
148+ // the flat directory listing for workspaces staged before the mapping.
149+ let display = null ;
150+ try {
151+ const idx = JSON . parse ( readFileSync ( join ( stagingDir , 'index.json' ) , 'utf8' ) ) ;
152+ if ( Array . isArray ( idx . entries ) && idx . entries . length > 0 ) {
153+ display = idx . entries . map ( e => e . path ) ;
154+ } else if ( Array . isArray ( idx . files ) ) {
155+ display = idx . files ;
156+ }
157+ } catch ( _ ) { }
158+ if ( ! display ) {
159+ display = readdirSync ( stagingDir ) . filter ( f => f !== 'index.json' ) ;
160+ }
138161
139162 console . log ( chalk . cyan ( '\nStaged Files:' ) ) ;
140163 console . log ( chalk . gray ( '-' . repeat ( 40 ) ) ) ;
141164
142- if ( files . length === 0 ) {
165+ if ( display . length === 0 ) {
143166 console . log ( chalk . yellow ( ' No files staged' ) ) ;
144167 console . log ( chalk . gray ( '\n Usage:' ) ) ;
145168 console . log ( chalk . gray ( ' cloudsync stage <files...> # Stage specific files' ) ) ;
146169 console . log ( chalk . gray ( ' cloudsync stage --all # Stage all' ) ) ;
147170 } else {
148- files . forEach ( f => {
171+ display . forEach ( f => {
149172 try {
150- const stat = statSync ( join ( stagingDir , f ) ) ;
173+ const flat = flattenName ( f ) ;
174+ const stat = statSync ( join ( stagingDir , flat ) ) ;
151175 const size = formatBytes ( stat . size ) ;
152176 console . log ( chalk . green ( ' + ' ) + chalk . white ( f ) + chalk . gray ( ` (${ size } )` ) ) ;
153177 } catch ( e ) {
@@ -156,27 +180,58 @@ function showStagedFiles(stagingDir, verbose) {
156180 } ) ;
157181
158182 console . log ( chalk . gray ( '-' . repeat ( 40 ) ) ) ;
159- console . log ( chalk . gray ( ` ${ files . length } file(s) staged` ) ) ;
183+ console . log ( chalk . gray ( ` ${ display . length } file(s) staged` ) ) ;
160184 }
161185}
162186
163187function saveStagedIndex ( files , verbose ) {
164- const indexFile = join ( process . cwd ( ) , '.cloudsync' , 'staging' , 'index.json' ) ;
165- // Atomic write: temp + rename — prevents races with concurrent stage invocations
166- const tmp = indexFile + '.tmp' ;
188+ const stagingDir = join ( process . cwd ( ) , '.cloudsync' , 'staging' ) ;
189+ const indexFile = join ( stagingDir , 'index.json' ) ;
190+
191+ // Build the staged-name -> original-path mapping. Union with any entries
192+ // already recorded (concurrent stage invocations), then intersect with the
193+ // files that actually exist on disk right now so unstage removals stick.
194+ const byStaged = new Map ( ) ;
195+ for ( const f of files ) {
196+ if ( f && typeof f === 'object' ) byStaged . set ( f . staged , f . path ) ;
197+ }
198+ try {
199+ const existing = JSON . parse ( readFileSync ( indexFile , 'utf8' ) ) ;
200+ if ( Array . isArray ( existing . entries ) ) {
201+ for ( const e of existing . entries ) byStaged . set ( e . staged , e . path ) ;
202+ }
203+ // Legacy index format (flat string list, pre-mapping) — carry over as-is
204+ if ( Array . isArray ( existing . files ) ) {
205+ for ( const f of existing . files ) byStaged . set ( f , f ) ;
206+ }
207+ } catch ( _ ) { /* no index yet */ }
208+
209+ let onDisk ;
210+ try {
211+ onDisk = new Set ( readdirSync ( stagingDir ) . filter ( f => f !== 'index.json' && ! f . endsWith ( '.tmp' ) ) ) ;
212+ } catch ( _ ) {
213+ onDisk = null ;
214+ }
215+
216+ const entries = [ ...byStaged . entries ( ) ]
217+ . map ( ( [ staged , path ] ) => ( { staged, path } ) )
218+ . filter ( e => onDisk === null || onDisk . has ( e . staged ) ) ;
219+
220+ const tmp = indexFile + `.${ process . pid } .tmp` ;
167221 writeFileSync ( tmp , JSON . stringify ( {
168- files,
222+ entries,
223+ files : entries . map ( e => e . path ) , // backward-compatible flat view
169224 timestamp : new Date ( ) . toISOString ( )
170225 } , null , 2 ) ) ;
171226 try {
172227 renameSync ( tmp , indexFile ) ;
173228 } catch ( e ) {
174- // Fallback: just copy if rename across mount fails
175- writeFileSync ( indexFile , JSON . stringify ( { files, timestamp : new Date ( ) . toISOString ( ) } , null , 2 ) ) ;
229+ // Fallback: direct write if rename fails (e.g. across mounts)
230+ writeFileSync ( indexFile , JSON . stringify ( { entries , files : entries . map ( e => e . path ) , timestamp : new Date ( ) . toISOString ( ) } , null , 2 ) ) ;
176231 try { unlinkSync ( tmp ) ; } catch ( _ ) { }
177232 }
178233
179- if ( verbose ) console . log ( chalk . gray ( `Staging index saved` ) ) ;
234+ if ( verbose ) console . log ( chalk . gray ( `Staging index saved ( ${ entries . length } files) ` ) ) ;
180235}
181236
182237export default stageCommand ;
0 commit comments