Skip to content

Commit c91dc4f

Browse files
dcramerclaude
andcommitted
feat: Add storage mode migration detection to doctor
When switching storage.file.mode between 'in-repo' and 'centralized', existing tasks are not automatically migrated. This can lead to "missing" tasks that are actually just in the old location. Changes: - doctor now detects tasks in the alternate storage location - doctor --fix migrates orphaned tasks to the current location - config command warns when changing storage.file.mode - Updated doctor help text to document storage location checks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6cbde3c commit c91dc4f

2 files changed

Lines changed: 232 additions & 2 deletions

File tree

src/cli/config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,24 @@ ${colors.bold}EXAMPLES:${colors.reset}
382382
try {
383383
const value = parseValue(key, rawValue, CONFIG_SCHEMA[key]);
384384

385+
// Check if changing storage mode - warn about potential orphaned tasks
386+
if (key === "storage.file.mode") {
387+
const currentConfig = readConfigFile(configPath);
388+
const currentMode = getNestedValue(currentConfig, key);
389+
if (currentMode && currentMode !== value) {
390+
console.log(
391+
`${colors.yellow}Warning:${colors.reset} Changing storage mode from '${currentMode}' to '${value}'.`,
392+
);
393+
console.log(
394+
`${colors.dim}Existing tasks will NOT be automatically migrated.${colors.reset}`,
395+
);
396+
console.log(
397+
`${colors.dim}Run 'dex doctor --fix' after this change to migrate tasks.${colors.reset}`,
398+
);
399+
console.log("");
400+
}
401+
}
402+
385403
const config = readConfigFile(configPath);
386404
setNestedValue(config, key, value);
387405
writeConfigFile(configPath, config);

src/cli/doctor.ts

Lines changed: 214 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
loadConfig,
1111
} from "../core/config.js";
1212
import { 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
*/
438446
async 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

Comments
 (0)