Skip to content

Commit 0935822

Browse files
committed
feat(cli,container): add git safe directory check, mount CLI, deeper volume scan, rebuild messaging
- Increase doctor volume detection depth from 4 to 8 to catch nested projects - Add git safe directory doctor check with immediate fix (no rebuild needed) - Auto-detect and register all git repos as safe.directory on container startup - Add `codeforge mount add <path>` command for manual volume registration - Extract shared mounts.json utilities to dedicated module - Standardize rebuild messaging with specific VS Code and CLI instructions - Add rebuildType field to FixAction for normal vs full rebuild guidance
1 parent 3f3f0b9 commit 0935822

13 files changed

Lines changed: 593 additions & 50 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { dirname } from "node:path";
2+
import type { CheckResult } from "../types.js";
3+
import { spawn } from "../util.js";
4+
5+
export async function checkGitSafeDirectories(
6+
workspaceRoot: string,
7+
): Promise<CheckResult> {
8+
// Find all git repos (directories) and worktrees (files with gitdir:)
9+
const [dirResult, fileResult] = await Promise.all([
10+
spawn("find", [
11+
workspaceRoot,
12+
"-maxdepth",
13+
"6",
14+
"-name",
15+
".git",
16+
"-type",
17+
"d",
18+
]),
19+
spawn("find", [
20+
workspaceRoot,
21+
"-maxdepth",
22+
"6",
23+
"-name",
24+
".git",
25+
"-type",
26+
"f",
27+
]),
28+
]);
29+
30+
const gitPaths = [
31+
...(dirResult.exitCode === 0 && dirResult.stdout
32+
? dirResult.stdout.split("\n").filter(Boolean)
33+
: []),
34+
...(fileResult.exitCode === 0 && fileResult.stdout
35+
? fileResult.stdout.split("\n").filter(Boolean)
36+
: []),
37+
];
38+
39+
if (gitPaths.length === 0) {
40+
return {
41+
name: "Git safe directories",
42+
category: "git",
43+
status: "pass",
44+
message: "no git repositories found",
45+
};
46+
}
47+
48+
// Get project roots from .git paths
49+
const projectDirs = [...new Set(gitPaths.map((p) => dirname(p)))];
50+
51+
// Get current safe.directory entries
52+
const { stdout: safeOutput } = await spawn("git", [
53+
"config",
54+
"--global",
55+
"--get-all",
56+
"safe.directory",
57+
]);
58+
59+
const safeDirs = new Set(
60+
safeOutput ? safeOutput.split("\n").filter(Boolean) : [],
61+
);
62+
63+
// Find project dirs not in the safe list
64+
const missing = projectDirs.filter((dir) => !safeDirs.has(dir));
65+
66+
if (missing.length === 0) {
67+
return {
68+
name: "Git safe directories",
69+
category: "git",
70+
status: "pass",
71+
message: `all ${projectDirs.length} project${projectDirs.length !== 1 ? "s" : ""} registered`,
72+
};
73+
}
74+
75+
return {
76+
name: "Git safe directories",
77+
category: "git",
78+
status: "warn",
79+
message: `${missing.length} project${missing.length !== 1 ? "s" : ""} missing safe.directory`,
80+
hint: "Run codeforge doctor --fix --only git",
81+
fix: {
82+
label: `Add ${missing.length} safe.directory entr${missing.length !== 1 ? "ies" : "y"}`,
83+
detail: `Registers ${missing.length} project director${missing.length !== 1 ? "ies" : "y"} as git safe directories to prevent "dubious ownership" errors (CVE-2022-24765). Takes effect immediately.`,
84+
impact: `${missing.length} git config change${missing.length !== 1 ? "s" : ""}`,
85+
requiresRebuild: false,
86+
apply: async () => {
87+
const added: string[] = [];
88+
for (const dir of missing) {
89+
const { exitCode } = await spawn("git", [
90+
"config",
91+
"--global",
92+
"--add",
93+
"safe.directory",
94+
dir,
95+
]);
96+
if (exitCode === 0) {
97+
added.push(dir);
98+
}
99+
}
100+
101+
if (added.length === 0) {
102+
return {
103+
applied: false,
104+
message: "Failed to add any safe.directory entries",
105+
};
106+
}
107+
108+
return {
109+
applied: true,
110+
message: `Added ${added.length} safe.directory entr${added.length !== 1 ? "ies" : "y"}: ${added.join(", ")}`,
111+
};
112+
},
113+
},
114+
};
115+
}

cli/src/commands/doctor/checks/volumes.ts

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { existsSync } from "node:fs";
2-
import { mkdir, readFile, writeFile } from "node:fs/promises";
2+
import { mkdir } from "node:fs/promises";
33
import { basename, dirname, join, relative } from "node:path";
4+
import { readMountsJson, writeMountsJson } from "../mounts.js";
5+
import type { MountEntry } from "../mounts.js";
46
import type { CheckResult } from "../types.js";
57
import { SLOW_FS_TYPES, spawn } from "../util.js";
68

@@ -9,6 +11,7 @@ interface VolumeCandidate {
911
contextFiles: string[];
1012
}
1113

14+
1215
const VOLUME_CANDIDATES: VolumeCandidate[] = [
1316
{ pattern: "node_modules", contextFiles: ["package.json"] },
1417
{
@@ -34,18 +37,6 @@ const VOLUME_CANDIDATES: VolumeCandidate[] = [
3437

3538
const CANDIDATE_NAMES = VOLUME_CANDIDATES.map((c) => c.pattern);
3639

37-
interface MountsJson {
38-
version: number;
39-
volumes: MountEntry[];
40-
}
41-
42-
interface MountEntry {
43-
path: string;
44-
source: "auto" | "user";
45-
signal: string;
46-
added: string;
47-
}
48-
4940
function hasContextFile(
5041
dirPath: string,
5142
contextFiles: string[],
@@ -77,19 +68,6 @@ function todayIso(): string {
7768
return new Date().toISOString().slice(0, 10);
7869
}
7970

80-
async function readMountsJson(filePath: string): Promise<MountsJson> {
81-
try {
82-
const raw = await readFile(filePath, "utf-8");
83-
const parsed = JSON.parse(raw) as MountsJson;
84-
if (parsed.version === 1 && Array.isArray(parsed.volumes)) {
85-
return parsed;
86-
}
87-
} catch {
88-
// file doesn't exist or is invalid
89-
}
90-
return { version: 1, volumes: [] };
91-
}
92-
9371
function sanitizeName(name: string): string {
9472
return name.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "");
9573
}
@@ -104,7 +82,7 @@ export async function checkVolumeCandidates(
10482
const { stdout, exitCode } = await spawn("find", [
10583
workspaceRoot,
10684
"-maxdepth",
107-
"4",
85+
"8",
10886
"-type",
10987
"d",
11088
"(",
@@ -161,6 +139,7 @@ export async function checkVolumeCandidates(
161139
detail: `Registers ${relPath} in .codeforge/mounts.json so CodeForge can mount it as a Docker volume instead of a bind mount, significantly improving I/O performance.`,
162140
impact: "requires rebuild",
163141
requiresRebuild: true,
142+
rebuildType: "normal",
164143
apply: async (): Promise<{
165144
applied: boolean;
166145
message: string;
@@ -193,11 +172,7 @@ export async function checkVolumeCandidates(
193172
mounts.volumes.push(entry);
194173
}
195174

196-
await writeFile(
197-
mountsPath,
198-
JSON.stringify(mounts, null, "\t") + "\n",
199-
"utf-8",
200-
);
175+
await writeMountsJson(mountsPath, mounts);
201176

202177
// Check if Docker Compose infrastructure exists
203178
const composeExists = existsSync(

cli/src/commands/doctor/fix.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface FixOptions {
1818
const CATEGORY_LABELS: Record<CheckCategory, string> = {
1919
auth: "Authentication",
2020
environment: "Environment",
21+
git: "Git",
2122
volumes: "Volumes",
2223
wsl: "WSL",
2324
};
@@ -26,6 +27,7 @@ const categoryMap: Record<string, CheckCategory> = {
2627
auth: "auth",
2728
env: "environment",
2829
environment: "environment",
30+
git: "git",
2931
volumes: "volumes",
3032
wsl: "wsl",
3133
};
@@ -79,9 +81,24 @@ async function applyFixes(fixes: CheckResult[]): Promise<void> {
7981
}
8082
}
8183

82-
const needsRebuild = fixes.some((f) => f.fix!.requiresRebuild);
83-
if (needsRebuild) {
84-
log.warn("Some changes require a container rebuild to take effect.");
84+
const rebuildFixes = fixes.filter((f) => f.fix!.requiresRebuild);
85+
if (rebuildFixes.length > 0) {
86+
const needsFull = rebuildFixes.some(
87+
(f) => f.fix!.rebuildType === "full",
88+
);
89+
if (needsFull) {
90+
log.warn(
91+
"Some changes require a full container rebuild (no cache) to take effect.\n" +
92+
" From VS Code: Ctrl+Shift+P → 'Dev Containers: Rebuild Without Cache'\n" +
93+
" From host terminal: codeforge container rebuild --no-cache",
94+
);
95+
} else {
96+
log.warn(
97+
"Some changes require a container rebuild to take effect.\n" +
98+
" From VS Code: Ctrl+Shift+P → 'Dev Containers: Rebuild Container'\n" +
99+
" From host terminal: codeforge container rebuild",
100+
);
101+
}
85102
}
86103
}
87104

@@ -107,7 +124,7 @@ export async function runFixMode(
107124
const cat = categoryMap[options.only];
108125
if (!cat) {
109126
console.error(
110-
`Unknown category: ${options.only}. Valid: auth, env, volumes, wsl`,
127+
`Unknown category: ${options.only}. Valid: auth, env, git, volumes, wsl`,
111128
);
112129
process.exit(1);
113130
}

cli/src/commands/doctor/format.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function formatText(report: DoctorReport, useColor: boolean): string {
3131
const categoryConfig: { key: CheckCategory; label: string }[] = [
3232
{ key: "auth", label: "Authentication" },
3333
{ key: "environment", label: "Environment" },
34+
{ key: "git", label: "Git" },
3435
{ key: "volumes", label: "Volumes" },
3536
{ key: "wsl", label: "WSL" },
3637
];

cli/src/commands/doctor/index.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
checkTmpdir,
77
checkWorkspaceFs,
88
} from "./checks/environment.js";
9+
import { checkGitSafeDirectories } from "./checks/git.js";
910
import { checkVolumeCandidates } from "./checks/volumes.js";
1011
import { checkDefenderExclusions, checkWslConfig } from "./checks/wsl.js";
1112
import { runFixMode } from "./fix.js";
@@ -21,7 +22,7 @@ export function registerDoctorCommand(parent: Command): void {
2122
.option("--fix", "Enter interactive fix mode")
2223
.option("-y, --yes", "Skip TUI prompts, apply all fixes")
2324
.option("--dry-run", "Show what --fix would change without applying")
24-
.option("--only <category>", "Filter fixes: auth, env, volumes, wsl")
25+
.option("--only <category>", "Filter fixes: auth, env, git, volumes, wsl")
2526
.action(async (options) => {
2627
const workspaceRoot = process.env.WORKSPACE_ROOT || "/workspaces";
2728

@@ -41,12 +42,14 @@ export function registerDoctorCommand(parent: Command): void {
4142
(c) => c.name === "Workspace filesystem" && c.status === "warn",
4243
);
4344

44-
// Run volume + WSL checks (WSL checks return null when not WSL)
45-
const [volumeChecks, wslConfigCheck, defenderCheck] = await Promise.all([
46-
checkVolumeCandidates(workspaceRoot),
47-
checkWslConfig(isWsl),
48-
checkDefenderExclusions(isWsl),
49-
]);
45+
// Run git + volume + WSL checks (WSL checks return null when not WSL)
46+
const [gitCheck, volumeChecks, wslConfigCheck, defenderCheck] =
47+
await Promise.all([
48+
checkGitSafeDirectories(workspaceRoot),
49+
checkVolumeCandidates(workspaceRoot),
50+
checkWslConfig(isWsl),
51+
checkDefenderExclusions(isWsl),
52+
]);
5053

5154
// Assemble all checks, filtering out nulls from WSL checks
5255
const wslChecks = [wslConfigCheck, defenderCheck].filter(
@@ -56,6 +59,7 @@ export function registerDoctorCommand(parent: Command): void {
5659
const checks: CheckResult[] = [
5760
...authChecks,
5861
...envChecks,
62+
gitCheck,
5963
...volumeChecks,
6064
...wslChecks,
6165
];

cli/src/commands/doctor/mounts.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { readFile, writeFile } from "node:fs/promises";
2+
3+
export interface MountsJson {
4+
version: number;
5+
volumes: MountEntry[];
6+
}
7+
8+
export interface MountEntry {
9+
path: string;
10+
source: "auto" | "user";
11+
signal: string;
12+
added: string;
13+
}
14+
15+
export async function readMountsJson(filePath: string): Promise<MountsJson> {
16+
try {
17+
const raw = await readFile(filePath, "utf-8");
18+
const parsed = JSON.parse(raw) as MountsJson;
19+
if (parsed.version === 1 && Array.isArray(parsed.volumes)) {
20+
return parsed;
21+
}
22+
} catch {
23+
// file doesn't exist or is invalid
24+
}
25+
return { version: 1, volumes: [] };
26+
}
27+
28+
export async function writeMountsJson(
29+
filePath: string,
30+
data: MountsJson,
31+
): Promise<void> {
32+
await writeFile(filePath, JSON.stringify(data, null, "\t") + "\n", "utf-8");
33+
}

cli/src/commands/doctor/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type CheckCategory = "auth" | "environment" | "volumes" | "wsl";
1+
export type CheckCategory = "auth" | "environment" | "git" | "volumes" | "wsl";
22

33
export interface FixResult {
44
applied: boolean;
@@ -11,6 +11,7 @@ export interface FixAction {
1111
detail: string; // full explanation
1212
impact: string; // "1 env var change" or "requires rebuild"
1313
requiresRebuild: boolean;
14+
rebuildType?: "normal" | "full"; // "normal" = recreate container, "full" = rebuild image (no cache)
1415
apply: () => Promise<FixResult>;
1516
}
1617

0 commit comments

Comments
 (0)