Skip to content

Commit 705ea01

Browse files
committed
fix(session-sync): back up Claude sessions from CLAUDE_CONFIG_DIR
docker-git points Claude Code at a custom CLAUDE_CONFIG_DIR, so chat transcripts land in "$CLAUDE_CONFIG_DIR/projects" rather than "~/.claude/projects". The backup only scanned home-relative paths, so the .claude folder in the docker-git-sessions backup repo stayed empty. Resolve each session root from its agent env override (CLAUDE_CONFIG_DIR for Claude, CODEX_HOME for Codex) with a home-relative fallback, keeping the logical .claude/projects / .codex/sessions names stable in the backup repo. Fixes #422
1 parent 85474e9 commit 705ea01

6 files changed

Lines changed: 133 additions & 7 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@prover-coder-ai/docker-git-session-sync": patch
3+
---
4+
5+
Back up Claude Code sessions from the resolved `CLAUDE_CONFIG_DIR` (issue #422).
6+
7+
docker-git points Claude Code at a custom `CLAUDE_CONFIG_DIR`
8+
(`~/.docker-git/.orch/auth/claude/<label>`), so Claude writes chat transcripts to
9+
`$CLAUDE_CONFIG_DIR/projects` instead of `~/.claude/projects`. The session backup
10+
only scanned the home-relative paths, so the `.claude` folder in the
11+
`docker-git-sessions` backup repo stayed empty.
12+
13+
The backup now resolves each session root from its agent environment override
14+
(`CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME` for Codex) and falls back to the
15+
home-relative directory when the override is unset, keeping the logical
16+
`.claude/projects` / `.codex/sessions` names stable in the backup repo.

packages/docker-git-session-sync/src/backup.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
isPathWithinParent,
1515
isChatTranscriptPath,
1616
sessionDirNames,
17+
sessionRootCandidatePaths,
18+
sessionRootSpecs,
1719
sessionWalkIgnoreDirNames,
1820
shouldIgnoreSessionPath,
1921
sortSessionFiles,
@@ -229,9 +231,16 @@ const allowedSessionRootDescription = sessionDirNames.map((dirName) => `~/${dirN
229231

230232
const getAllowedSessionRoots = (): ReadonlyArray<SessionDir> => {
231233
const homeDir = os.homedir()
232-
return sessionDirNames
233-
.map((dirName) => ({ name: dirName, path: path.join(homeDir, dirName) }))
234-
.filter((entry) => fs.existsSync(entry.path))
234+
const roots: Array<SessionDir> = []
235+
for (const spec of sessionRootSpecs) {
236+
const existing = sessionRootCandidatePaths(spec, homeDir, process.env).find((candidatePath) =>
237+
fs.existsSync(candidatePath)
238+
)
239+
if (existing !== undefined) {
240+
roots.push({ name: spec.name, path: existing })
241+
}
242+
}
243+
return roots
235244
}
236245

237246
const resolveAllowedSessionDir = (

packages/docker-git-session-sync/src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const usageText = `Usage:
1212
docker-git-session-sync download <snapshot-ref> [options]
1313
1414
Options:
15-
--session-dir <path> Path under ~/.codex/sessions or ~/.claude/projects
15+
--session-dir <path> Path under \${CODEX_HOME:-~/.codex}/sessions or \${CLAUDE_CONFIG_DIR:-~/.claude}/projects
1616
--pr-number <number> Open PR number to post comment to
1717
--repo <owner/repo> Source repository or list filter
1818
--limit <number> Maximum snapshots to list (default: 20)

packages/docker-git-session-sync/src/core.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,51 @@ export const backupDefaultBranch = "main"
1616
export const chunkManifestSuffix = ".chunks.json"
1717
export const maxRepoFileSize = 20 * 1000 * 1000
1818
export const maxPushBatchBytes = 50 * 1000 * 1000
19-
export const sessionDirNames: ReadonlyArray<string> = [".codex/sessions", ".claude/projects"]
19+
// CHANGE: Resolve session roots from agent env overrides (CLAUDE_CONFIG_DIR / CODEX_HOME).
20+
// WHY: docker-git points Claude Code at a custom CLAUDE_CONFIG_DIR, so chat transcripts land in
21+
// "$CLAUDE_CONFIG_DIR/projects" rather than "~/.claude/projects". The backup only scanned the
22+
// home-relative paths, so the Claude folder in the backup repo stayed empty (issue #422).
23+
// QUOTE(ТЗ): "Почему сессии от claude code не созраняются ... Папка claude вообще пустая"
24+
// REF: issue-422
25+
// SOURCE: n/a
26+
// FORMAT THEOREM: ∀spec,home,env: candidatePaths(spec) prefers env override base then home base.
27+
// PURITY: CORE
28+
// EFFECT: none
29+
// INVARIANT: logical session name is stable regardless of which physical base is used.
30+
// COMPLEXITY: O(1)
31+
export interface SessionRootSpec {
32+
// Logical name used inside the backup repo (e.g. ".claude/projects").
33+
readonly name: string
34+
// Environment variable that overrides the base directory, when set and non-empty.
35+
readonly envVar: string | null
36+
// Sub-directory holding chat transcripts within the base directory.
37+
readonly subDir: string
38+
// Base directory relative to the user home when the env override is absent.
39+
readonly homeBase: string
40+
}
41+
42+
export const sessionRootSpecs: ReadonlyArray<SessionRootSpec> = [
43+
{ name: ".codex/sessions", envVar: "CODEX_HOME", subDir: "sessions", homeBase: ".codex" },
44+
{ name: ".claude/projects", envVar: "CLAUDE_CONFIG_DIR", subDir: "projects", homeBase: ".claude" }
45+
]
46+
47+
export const sessionDirNames: ReadonlyArray<string> = sessionRootSpecs.map((spec) => spec.name)
48+
49+
export const sessionRootCandidatePaths = (
50+
spec: SessionRootSpec,
51+
homeDir: string,
52+
env: Readonly<Record<string, string | undefined>>
53+
): ReadonlyArray<string> => {
54+
const homePath = path.join(homeDir, spec.homeBase, spec.subDir)
55+
const override = spec.envVar === null ? undefined : env[spec.envVar]
56+
const trimmed = typeof override === "string" ? override.trim() : ""
57+
if (trimmed.length === 0) {
58+
return [homePath]
59+
}
60+
const overridePath = path.join(trimmed, spec.subDir)
61+
return overridePath === homePath ? [homePath] : [overridePath, homePath]
62+
}
63+
2064
export const sessionWalkIgnoreDirNames: ReadonlySet<string> = new Set([".git", "node_modules", "tmp"])
2165
export const githubEnvKeys: ReadonlyArray<string> = ["GITHUB_TOKEN", "GH_TOKEN"]
2266

packages/docker-git-session-sync/src/snapshots.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export const downloadSnapshot = (options: DownloadOptions, cwd: string, output:
183183

184184
output.out(`Downloaded snapshot to: ${outputPath}`)
185185
output.out("\nTo restore session files, copy them to the appropriate location:")
186-
output.out(" - .codex/sessions/... -> ~/.codex/sessions/")
187-
output.out(" - .claude/projects/... -> ~/.claude/projects/")
186+
output.out(" - .codex/sessions/... -> ${CODEX_HOME:-~/.codex}/sessions/")
187+
output.out(" - .claude/projects/... -> ${CLAUDE_CONFIG_DIR:-~/.claude}/projects/")
188188
return 0
189189
}

packages/docker-git-session-sync/tests/session-files.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
formatTokenReduction,
1010
isChatTranscriptPath,
1111
maxRepoFileSize,
12+
sessionRootCandidatePaths,
13+
sessionRootSpecs,
1214
shouldIgnoreSessionPath,
1315
summarizeTokenReduction
1416
} from "../src/core.js"
@@ -87,6 +89,61 @@ describe("session path filtering", () => {
8789
})
8890
})
8991

92+
describe("session root resolution", () => {
93+
const codexSpec = sessionRootSpecs.find((spec) => spec.name === ".codex/sessions")
94+
const claudeSpec = sessionRootSpecs.find((spec) => spec.name === ".claude/projects")
95+
96+
it("resolves Claude session roots from CLAUDE_CONFIG_DIR (issue #422)", () => {
97+
expect(claudeSpec).toBeDefined()
98+
if (claudeSpec === undefined) {
99+
return
100+
}
101+
const configDir = path.join(tmpDir, ".docker-git", ".orch", "auth", "claude", "default")
102+
const candidates = sessionRootCandidatePaths(claudeSpec, "/home/dev", {
103+
CLAUDE_CONFIG_DIR: configDir
104+
})
105+
106+
// The env override wins, but the home-relative path stays as a fallback.
107+
expect(candidates).toEqual([
108+
path.join(configDir, "projects"),
109+
path.join("/home/dev", ".claude", "projects")
110+
])
111+
})
112+
113+
it("falls back to the home directory when the env override is empty", () => {
114+
expect(codexSpec).toBeDefined()
115+
if (codexSpec === undefined || claudeSpec === undefined) {
116+
return
117+
}
118+
expect(sessionRootCandidatePaths(codexSpec, "/home/dev", {})).toEqual([
119+
path.join("/home/dev", ".codex", "sessions")
120+
])
121+
expect(sessionRootCandidatePaths(claudeSpec, "/home/dev", { CLAUDE_CONFIG_DIR: " " })).toEqual([
122+
path.join("/home/dev", ".claude", "projects")
123+
])
124+
})
125+
126+
it("resolves Codex session roots from CODEX_HOME", () => {
127+
if (codexSpec === undefined) {
128+
return
129+
}
130+
const codexHome = path.join(tmpDir, "codex-home")
131+
expect(sessionRootCandidatePaths(codexSpec, "/home/dev", { CODEX_HOME: codexHome })).toEqual([
132+
path.join(codexHome, "sessions"),
133+
path.join("/home/dev", ".codex", "sessions")
134+
])
135+
})
136+
137+
it("collapses to a single candidate when the override matches the home path", () => {
138+
if (claudeSpec === undefined) {
139+
return
140+
}
141+
expect(
142+
sessionRootCandidatePaths(claudeSpec, "/home/dev", { CLAUDE_CONFIG_DIR: "/home/dev/.claude" })
143+
).toEqual([path.join("/home/dev", ".claude", "projects")])
144+
})
145+
})
146+
90147
describe("snapshot refs", () => {
91148
it("uses stable current refs for PR and branch snapshots", () => {
92149
expect(buildSnapshotRef("org/repo", 230, "issue-230")).toBe("org/repo/pr-230/current")

0 commit comments

Comments
 (0)