@@ -10,6 +10,9 @@ import {
1010 loadConfig ,
1111} from "../core/config.js" ;
1212import { getGitHubToken } from "../core/github/index.js" ;
13+ import { getDefaultStoragePath , findGitRoot } from "../core/storage/paths.js" ;
14+ import { getProjectKey } from "../core/project-key.js" ;
15+ import { getDexHome } from "../core/config.js" ;
1316
1417/**
1518 * Default auto-sync configuration to add when missing.
@@ -55,6 +58,10 @@ ${colors.bold}OPTIONS:${colors.reset}
5558 -h, --help Show this help message
5659
5760${ colors . bold } CHECKS:${ colors . reset }
61+ Storage Location:
62+ - Tilde expansion bug (v0.4.0)
63+ - Orphaned tasks from storage mode changes (in-repo ↔ centralized)
64+
5865 Config:
5966 - Config file validity (valid TOML)
6067 - Missing fields (new defaults not in config)
@@ -432,13 +439,16 @@ async function checkStorage(
432439}
433440
434441/**
435- * Check for tasks stored in wrong location due to tilde expansion bug (v0.4.0).
436- * Tasks may be in literal "~/.dex/tasks/" instead of ".dex/tasks/".
442+ * Check for tasks stored in wrong location.
443+ * - Tilde expansion bug (v0.4.0): Tasks in literal "~/.dex/tasks/"
444+ * - Storage mode change: Tasks in old location after switching modes
437445 */
438446async function checkStorageLocation (
439447 options : CliOptions ,
440448) : Promise < DoctorIssue [ ] > {
441449 const issues : DoctorIssue [ ] = [ ] ;
450+ const config = loadConfig ( ) ;
451+ const currentMode = config . storage . file ?. mode ?? "in-repo" ;
442452
443453 // Check for literal tilde directory in current working directory
444454 const literalTildePath = path . join ( process . cwd ( ) , "~" , ".dex" , "tasks" ) ;
@@ -460,9 +470,211 @@ async function checkStorageLocation(
460470 }
461471 }
462472
473+ // Check for orphaned tasks from storage mode changes
474+ const orphanedIssues = await checkOrphanedStorageLocations (
475+ options ,
476+ currentMode ,
477+ ) ;
478+ issues . push ( ...orphanedIssues ) ;
479+
463480 return issues ;
464481}
465482
483+ /**
484+ * Check for tasks left behind after changing storage.file.mode.
485+ * When switching from in-repo to centralized (or vice versa), tasks don't auto-migrate.
486+ */
487+ async function checkOrphanedStorageLocations (
488+ options : CliOptions ,
489+ currentMode : "in-repo" | "centralized" ,
490+ ) : Promise < DoctorIssue [ ] > {
491+ const issues : DoctorIssue [ ] = [ ] ;
492+ const currentStoragePath = options . storage . getIdentifier ( ) ;
493+
494+ // Determine the alternate storage path
495+ let alternateStoragePath : string | null = null ;
496+ let alternateMode : string = "" ;
497+
498+ if ( currentMode === "centralized" ) {
499+ // Current mode is centralized, check for tasks in in-repo location
500+ const gitRoot = findGitRoot ( process . cwd ( ) ) ;
501+ if ( gitRoot ) {
502+ alternateStoragePath = path . join ( gitRoot , ".dex" ) ;
503+ alternateMode = "in-repo (.dex/)" ;
504+ }
505+ } else {
506+ // Current mode is in-repo, check for tasks in centralized location
507+ const projectKey = getProjectKey ( ) ;
508+ alternateStoragePath = path . join ( getDexHome ( ) , "projects" , projectKey ) ;
509+ alternateMode = `centralized (~/.dex/projects/${ projectKey } /)` ;
510+ }
511+
512+ if ( alternateStoragePath && alternateStoragePath !== currentStoragePath ) {
513+ const taskCount = countTasksInLocation ( alternateStoragePath ) ;
514+
515+ if ( taskCount > 0 ) {
516+ const targetPath = currentStoragePath ;
517+ issues . push ( {
518+ type : "warning" ,
519+ category : "migration" ,
520+ message : `Found ${ taskCount } task(s) in previous ${ alternateMode } location. These were not migrated when storage mode changed.` ,
521+ fix : async ( ) => {
522+ await migrateStorageLocation ( alternateStoragePath ! , targetPath ) ;
523+ } ,
524+ } ) ;
525+ }
526+ }
527+
528+ return issues ;
529+ }
530+
531+ /**
532+ * Count tasks in a storage location (checks both JSONL and individual file formats).
533+ */
534+ function countTasksInLocation ( storagePath : string ) : number {
535+ // Check JSONL format
536+ const jsonlPath = path . join ( storagePath , "tasks.jsonl" ) ;
537+ if ( fs . existsSync ( jsonlPath ) ) {
538+ try {
539+ const content = fs . readFileSync ( jsonlPath , "utf-8" ) ;
540+ const lines = content
541+ . trim ( )
542+ . split ( "\n" )
543+ . filter ( ( line ) => line . trim ( ) ) ;
544+ return lines . length ;
545+ } catch {
546+ // Ignore read errors
547+ }
548+ }
549+
550+ // Check individual file format
551+ const tasksDir = path . join ( storagePath , "tasks" ) ;
552+ if ( fs . existsSync ( tasksDir ) ) {
553+ try {
554+ const files = fs . readdirSync ( tasksDir ) . filter ( ( f ) => f . endsWith ( ".json" ) ) ;
555+ return files . length ;
556+ } catch {
557+ // Ignore read errors
558+ }
559+ }
560+
561+ return 0 ;
562+ }
563+
564+ /**
565+ * Migrate tasks from one storage location to another.
566+ * Handles both JSONL and individual file formats.
567+ */
568+ async function migrateStorageLocation (
569+ sourcePath : string ,
570+ targetPath : string ,
571+ ) : Promise < void > {
572+ let migratedCount = 0 ;
573+
574+ // Ensure target directory exists
575+ fs . mkdirSync ( targetPath , { recursive : true } ) ;
576+
577+ // Migrate JSONL format
578+ const sourceJsonl = path . join ( sourcePath , "tasks.jsonl" ) ;
579+ const targetJsonl = path . join ( targetPath , "tasks.jsonl" ) ;
580+
581+ if ( fs . existsSync ( sourceJsonl ) ) {
582+ // Read source tasks
583+ const sourceContent = fs . readFileSync ( sourceJsonl , "utf-8" ) ;
584+ const sourceLines = sourceContent
585+ . trim ( )
586+ . split ( "\n" )
587+ . filter ( ( line ) => line . trim ( ) ) ;
588+
589+ // If target exists, merge; otherwise just copy
590+ if ( fs . existsSync ( targetJsonl ) ) {
591+ const targetContent = fs . readFileSync ( targetJsonl , "utf-8" ) ;
592+ const targetLines = targetContent
593+ . trim ( )
594+ . split ( "\n" )
595+ . filter ( ( line ) => line . trim ( ) ) ;
596+
597+ // Parse to get IDs and avoid duplicates
598+ const targetIds = new Set < string > ( ) ;
599+ for ( const line of targetLines ) {
600+ try {
601+ const task = JSON . parse ( line ) ;
602+ if ( task . id ) targetIds . add ( task . id ) ;
603+ } catch {
604+ // Skip invalid lines
605+ }
606+ }
607+
608+ // Append non-duplicate tasks
609+ const newLines : string [ ] = [ ] ;
610+ for ( const line of sourceLines ) {
611+ try {
612+ const task = JSON . parse ( line ) ;
613+ if ( task . id && ! targetIds . has ( task . id ) ) {
614+ newLines . push ( line ) ;
615+ migratedCount ++ ;
616+ }
617+ } catch {
618+ // Skip invalid lines
619+ }
620+ }
621+
622+ if ( newLines . length > 0 ) {
623+ fs . appendFileSync ( targetJsonl , "\n" + newLines . join ( "\n" ) ) ;
624+ }
625+ } else {
626+ fs . copyFileSync ( sourceJsonl , targetJsonl ) ;
627+ migratedCount = sourceLines . length ;
628+ }
629+
630+ // Remove source file after successful migration
631+ fs . unlinkSync ( sourceJsonl ) ;
632+ }
633+
634+ // Migrate individual file format
635+ const sourceTasksDir = path . join ( sourcePath , "tasks" ) ;
636+ const targetTasksDir = path . join ( targetPath , "tasks" ) ;
637+
638+ if ( fs . existsSync ( sourceTasksDir ) ) {
639+ fs . mkdirSync ( targetTasksDir , { recursive : true } ) ;
640+
641+ const taskFiles = fs
642+ . readdirSync ( sourceTasksDir )
643+ . filter ( ( f ) => f . endsWith ( ".json" ) ) ;
644+ for ( const file of taskFiles ) {
645+ const sourceFile = path . join ( sourceTasksDir , file ) ;
646+ const targetFile = path . join ( targetTasksDir , file ) ;
647+
648+ // Only copy if target doesn't exist (avoid overwriting newer data)
649+ if ( ! fs . existsSync ( targetFile ) ) {
650+ fs . copyFileSync ( sourceFile , targetFile ) ;
651+ migratedCount ++ ;
652+ }
653+
654+ // Remove source file
655+ fs . unlinkSync ( sourceFile ) ;
656+ }
657+
658+ // Clean up empty directory
659+ try {
660+ fs . rmdirSync ( sourceTasksDir ) ;
661+ } catch {
662+ // Directory may not be empty
663+ }
664+ }
665+
666+ console . log (
667+ ` Migrated ${ migratedCount } task(s) from ${ sourcePath } to ${ targetPath } ` ,
668+ ) ;
669+
670+ // Clean up empty source directory
671+ try {
672+ fs . rmdirSync ( sourcePath ) ;
673+ } catch {
674+ // Directory may not be empty or may have other files
675+ }
676+ }
677+
466678/**
467679 * Migrate tasks from wrong location to correct location.
468680 */
0 commit comments