claude-git-sessions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -0
- package/dist/branch-sessions.js +33 -0
- package/dist/commands/delete.js +86 -0
- package/dist/commands/pull.js +69 -0
- package/dist/commands/push.js +91 -0
- package/dist/constants.js +19 -0
- package/dist/discover.js +61 -0
- package/dist/git.js +194 -0
- package/dist/index.js +76 -0
- package/dist/match.js +32 -0
- package/dist/meta.js +44 -0
- package/dist/paths.js +25 -0
- package/dist/session.js +114 -0
- package/dist/slug.js +27 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# ccgs — Claude Code Git Sessions
|
|
2
|
+
|
|
3
|
+
Share [Claude Code](https://claude.com/claude-code) sessions across a team
|
|
4
|
+
through an **orphan branch in your existing git repo**. No server, no extra
|
|
5
|
+
infrastructure — your transcripts ride along with the code they belong to.
|
|
6
|
+
|
|
7
|
+
> Published on npm as **`claude-git-sessions`** (the bare name `ccgs` is blocked by npm's
|
|
8
|
+
> name-similarity filter). The command it installs is **`ccgs`**.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx claude-git-sessions pull # bring shared sessions onto this machine
|
|
12
|
+
npx claude-git-sessions push # publish your local sessions for this repo
|
|
13
|
+
npx claude-git-sessions delete <id|name>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
After `pull`, resume any teammate's session locally with:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
claude --resume <session-id>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Run on demand with `npx` (no install):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx claude-git-sessions pull
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
…or install globally (this installs the `ccgs` command):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm i -g claude-git-sessions
|
|
34
|
+
ccgs --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires **Node 20+** and **git 2.5+**. Run it from inside your git repo.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
Sessions are stored on an **orphan branch** named `@ccgs/<name>` (default
|
|
42
|
+
`@ccgs/default`) on your repo's remote (default `origin`). The branch shares no
|
|
43
|
+
history with `main` and never contains your source files — only session data:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
sessions/<session-id>.jsonl # the transcript, verbatim
|
|
47
|
+
sessions/<session-id>.meta.json # sidecar metadata (name, author, cwd, …)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Files are keyed by the globally-unique Claude Code session UUID, so transcripts
|
|
51
|
+
from different authors never collide.
|
|
52
|
+
|
|
53
|
+
Every `ccgs` operation is done with low-level git plumbing
|
|
54
|
+
(`hash-object`/`update-index`/`write-tree`/`commit-tree`/`push`) against a
|
|
55
|
+
private temporary index. **Your working tree, index, and current branch are
|
|
56
|
+
never touched**, and everything works even with a dirty tree. Concurrent pushes
|
|
57
|
+
are handled by re-fetching and replaying on a non-fast-forward rejection.
|
|
58
|
+
|
|
59
|
+
### Branch namespacing
|
|
60
|
+
|
|
61
|
+
The `@ccgs/<name>` namespace lets you keep separate session sets. Use the
|
|
62
|
+
default for the team's shared sessions, and a private name for your own
|
|
63
|
+
work-in-progress that you don't want to share yet:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ccgs push -b my-wip # publish to @ccgs/my-wip
|
|
67
|
+
ccgs pull -b my-wip # only your private set
|
|
68
|
+
ccgs push # the shared @ccgs/default set
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Branch names are validated with `git check-ref-format` before use.
|
|
72
|
+
|
|
73
|
+
## Commands
|
|
74
|
+
|
|
75
|
+
### `ccgs pull`
|
|
76
|
+
|
|
77
|
+
Fetches `@ccgs/<name>` and writes each session where Claude Code expects it:
|
|
78
|
+
`~/.claude/projects/<local-slug>/<id>.jsonl`. The structural `cwd` field in each
|
|
79
|
+
transcript line is rewritten from the author's path to your local equivalent so
|
|
80
|
+
`claude --resume` works. (Absolute paths inside tool *output* are left as-is —
|
|
81
|
+
cosmetic only.)
|
|
82
|
+
|
|
83
|
+
If a local session is **newer** than the shared copy it is skipped with a
|
|
84
|
+
warning; pass `--force` to overwrite anyway. Prints what was pulled / skipped.
|
|
85
|
+
|
|
86
|
+
If the branch doesn't exist, it prints `nothing to pull` and exits 0.
|
|
87
|
+
|
|
88
|
+
### `ccgs push [targets...]`
|
|
89
|
+
|
|
90
|
+
Finds local sessions whose working directory is this repo (or a subdirectory),
|
|
91
|
+
copies each transcript verbatim onto the orphan branch, and (re)generates its
|
|
92
|
+
`meta.json`. With no arguments it pushes all of them; pass session ids/names to
|
|
93
|
+
push only those. Creates the orphan branch on first push. Reports added vs
|
|
94
|
+
updated.
|
|
95
|
+
|
|
96
|
+
### `ccgs delete <id|name> [--yes] [--local]`
|
|
97
|
+
|
|
98
|
+
Resolves the target by full UUID, unambiguous UUID prefix (git-style, ≥4
|
|
99
|
+
chars), or unique display name — listing candidates and aborting if ambiguous.
|
|
100
|
+
Shows what will be deleted and asks for `y/N` confirmation (`--yes`/`-y` to skip
|
|
101
|
+
for scripting). Removes both files from the branch and pushes. By default only
|
|
102
|
+
the shared branch is touched; add `--local` to also remove the local copy.
|
|
103
|
+
|
|
104
|
+
### Global options
|
|
105
|
+
|
|
106
|
+
| Option | Default | Meaning |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `-b, --branch <name>` | `default` | session set → branch `@ccgs/<name>` |
|
|
109
|
+
| `--remote <remote>` | `origin` | git remote to use |
|
|
110
|
+
| `-v, --version` | | print version |
|
|
111
|
+
| `-h, --help` | | help |
|
|
112
|
+
|
|
113
|
+
## Step 0 findings — how Claude Code stores sessions
|
|
114
|
+
|
|
115
|
+
These assumptions were verified empirically against a real `~/.claude/` before
|
|
116
|
+
writing the tool. They live in one place each so they're easy to fix if the
|
|
117
|
+
convention changes.
|
|
118
|
+
|
|
119
|
+
### Project slug encoding (`src/slug.ts`)
|
|
120
|
+
|
|
121
|
+
Claude Code stores each project's sessions under
|
|
122
|
+
`~/.claude/projects/<slug>/`, where the slug is derived from the absolute
|
|
123
|
+
working directory. Comparing real directories to the `cwd` recorded inside their
|
|
124
|
+
session files showed the rule is: **replace every character that is not
|
|
125
|
+
`[A-Za-z0-9]` with `-`**.
|
|
126
|
+
|
|
127
|
+
| Working directory | Slug |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `/home/user` | `-home-user` |
|
|
130
|
+
| `/home/user/code/api` | `-home-user-code-api` |
|
|
131
|
+
| `/home/user/code/my.app.dev` | `-home-user-code-my-app-dev` |
|
|
132
|
+
| `/home/user/code/web-client` | `-home-user-code-web-client` |
|
|
133
|
+
|
|
134
|
+
Note that both `/` and `.` map to `-`, so the encoding is **lossy and
|
|
135
|
+
irreversible**. ccgs therefore only ever goes path → slug, and stores the real
|
|
136
|
+
`originalCwd` separately in `meta.json`.
|
|
137
|
+
|
|
138
|
+
The config root honors `CLAUDE_CONFIG_DIR` and falls back to `~/.claude`. Local
|
|
139
|
+
sessions live at `<config>/projects/<slug>/<session-id>.jsonl`.
|
|
140
|
+
|
|
141
|
+
### Session `.jsonl` schema (`src/session.ts`)
|
|
142
|
+
|
|
143
|
+
Each session is **JSONL** — one JSON object per line, with many object `type`s
|
|
144
|
+
(`user`, `assistant`, `attachment`, `ai-title`, `mode`, `tool_result`, …).
|
|
145
|
+
Verified fields:
|
|
146
|
+
|
|
147
|
+
- **Session UUID** — `sessionId`, present on most lines, equal to the filename.
|
|
148
|
+
- **Title / name** — a `{ "type": "ai-title", "aiTitle": "…" }` line. Older
|
|
149
|
+
versions used `{ "type": "summary", "summary": "…" }`; some objects also carry
|
|
150
|
+
a `title`. The display name resolves: title → summary → first user message
|
|
151
|
+
(truncated) → session id.
|
|
152
|
+
- **Working directory** — the `cwd` field, present on `user`/`assistant`/
|
|
153
|
+
`attachment` lines. This is the **only** field `pull` rewrites.
|
|
154
|
+
- **Timestamps** — per-line `timestamp` (ISO-8601). `updatedAt` is the max.
|
|
155
|
+
- **First user message** — `message.content` on the first `type:"user"` line;
|
|
156
|
+
`content` is a string or an array of content blocks (each may have `.text`).
|
|
157
|
+
|
|
158
|
+
## Known considerations / future work
|
|
159
|
+
|
|
160
|
+
- **Secret redaction** — transcripts are pushed verbatim. If your sessions may
|
|
161
|
+
contain secrets, scrub them before pushing. A `--redact` flag is a likely
|
|
162
|
+
future addition. **Not implemented today.**
|
|
163
|
+
- **Git LFS** — very large transcripts are committed as ordinary blobs. LFS
|
|
164
|
+
handling may be added later.
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
npm install
|
|
170
|
+
npm run build # tsc -> dist/, chmod +x the bin
|
|
171
|
+
npm test # node:test unit tests (no live Claude install needed)
|
|
172
|
+
npm run typecheck
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Tests cover the pure pieces: slug encoding, display-name resolution,
|
|
176
|
+
id/prefix/name matching, and the `cwd` remap transform.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { SESSIONS_DIR } from "./constants.js";
|
|
2
|
+
import { lsSessions, showFile } from "./git.js";
|
|
3
|
+
import { parseMeta } from "./meta.js";
|
|
4
|
+
/** Session ids present on the branch ref (derived from `.jsonl` blobs). */
|
|
5
|
+
export async function listBranchSessionIds(repo, ref) {
|
|
6
|
+
const paths = await lsSessions(repo, ref);
|
|
7
|
+
const ids = new Set();
|
|
8
|
+
for (const p of paths) {
|
|
9
|
+
const m = p.match(new RegExp(`^${SESSIONS_DIR}/([^/]+)\\.jsonl$`));
|
|
10
|
+
if (m)
|
|
11
|
+
ids.add(m[1]);
|
|
12
|
+
}
|
|
13
|
+
return [...ids];
|
|
14
|
+
}
|
|
15
|
+
/** Read every session id + its parsed sidecar meta from the branch ref. */
|
|
16
|
+
export async function listBranchSessions(repo, ref) {
|
|
17
|
+
const ids = await listBranchSessionIds(repo, ref);
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const id of ids) {
|
|
20
|
+
const raw = await showFile(repo, ref, `${SESSIONS_DIR}/${id}.meta.json`);
|
|
21
|
+
let meta = null;
|
|
22
|
+
if (raw) {
|
|
23
|
+
try {
|
|
24
|
+
meta = parseMeta(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
meta = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
out.push({ id, meta });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { SESSIONS_DIR } from "../constants.js";
|
|
5
|
+
import { applyToOrphanBranch, assertRemote, fetchBranch, localTrackingRef, validateBranchName, } from "../git.js";
|
|
6
|
+
import { listBranchSessions } from "../branch-sessions.js";
|
|
7
|
+
import { resolveTarget, shortId } from "../match.js";
|
|
8
|
+
import { sessionFilePath } from "../paths.js";
|
|
9
|
+
function confirm(question) {
|
|
10
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
rl.question(question, (answer) => {
|
|
13
|
+
rl.close();
|
|
14
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export async function deleteSession(opts) {
|
|
19
|
+
await validateBranchName(opts.repoRoot, opts.branch);
|
|
20
|
+
await assertRemote(opts.repoRoot, opts.remote);
|
|
21
|
+
const exists = await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
|
|
22
|
+
if (!exists) {
|
|
23
|
+
console.log("nothing to delete (branch does not exist on remote)");
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
const ref = localTrackingRef(opts.branch);
|
|
27
|
+
const sessions = await listBranchSessions(opts.repoRoot, ref);
|
|
28
|
+
if (sessions.length === 0) {
|
|
29
|
+
console.log("nothing to delete (no sessions on branch)");
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
const candidates = sessions.map((s) => ({
|
|
33
|
+
id: s.id,
|
|
34
|
+
name: s.meta?.name ?? s.id,
|
|
35
|
+
meta: s.meta,
|
|
36
|
+
}));
|
|
37
|
+
const res = resolveTarget(opts.target, candidates);
|
|
38
|
+
if (res.kind === "none") {
|
|
39
|
+
console.error(`"${opts.target}" matched no session on @ccgs/${opts.branch}`);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
if (res.kind === "ambiguous") {
|
|
43
|
+
console.error(`"${opts.target}" is ambiguous; candidates:`);
|
|
44
|
+
for (const c of res.items)
|
|
45
|
+
console.error(` ${shortId(c.id)} ${c.name}`);
|
|
46
|
+
console.error("Refine your query (use a longer id prefix or exact name).");
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const item = res.item;
|
|
50
|
+
const meta = item.meta;
|
|
51
|
+
console.log("Will delete:");
|
|
52
|
+
console.log(` id: ${item.id}`);
|
|
53
|
+
console.log(` name: ${meta?.name ?? "(unknown)"}`);
|
|
54
|
+
console.log(` author: ${meta?.author ?? "(unknown)"}`);
|
|
55
|
+
console.log(` updatedAt: ${meta?.updatedAt ?? "(unknown)"}`);
|
|
56
|
+
if (!opts.yes) {
|
|
57
|
+
const ok = await confirm(`Delete this session from @ccgs/${opts.branch}? [y/N] `);
|
|
58
|
+
if (!ok) {
|
|
59
|
+
console.log("Aborted.");
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const result = await applyToOrphanBranch(opts.repoRoot, opts.remote, opts.branch, `ccgs: delete session ${shortId(item.id)}`, async (b) => {
|
|
64
|
+
await b.removeBlob(`${SESSIONS_DIR}/${item.id}.jsonl`);
|
|
65
|
+
await b.removeBlob(`${SESSIONS_DIR}/${item.id}.meta.json`);
|
|
66
|
+
});
|
|
67
|
+
if (result.changed) {
|
|
68
|
+
console.log(`Deleted ${item.name} (${shortId(item.id)}) from ${opts.remote} @ccgs/${opts.branch}.`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
console.log("Nothing changed on the branch (already absent).");
|
|
72
|
+
}
|
|
73
|
+
if (opts.local) {
|
|
74
|
+
const rel = meta?.cwdRelativeToRepoRoot ?? "";
|
|
75
|
+
const localCwd = rel ? path.join(opts.repoRoot, rel) : opts.repoRoot;
|
|
76
|
+
const localFile = sessionFilePath(localCwd, item.id);
|
|
77
|
+
if (fs.existsSync(localFile)) {
|
|
78
|
+
fs.rmSync(localFile);
|
|
79
|
+
console.log(`Removed local copy: ${localFile}`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
console.log("No local copy found to remove.");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SESSIONS_DIR } from "../constants.js";
|
|
4
|
+
import { assertRemote, fetchBranch, localTrackingRef, showFile, validateBranchName, } from "../git.js";
|
|
5
|
+
import { listBranchSessions } from "../branch-sessions.js";
|
|
6
|
+
import { parseSession, remapCwd } from "../session.js";
|
|
7
|
+
import { projectDirForCwd, sessionFilePath } from "../paths.js";
|
|
8
|
+
import { shortId } from "../match.js";
|
|
9
|
+
export async function pull(opts) {
|
|
10
|
+
await validateBranchName(opts.repoRoot, opts.branch);
|
|
11
|
+
await assertRemote(opts.repoRoot, opts.remote);
|
|
12
|
+
const exists = await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
|
|
13
|
+
if (!exists) {
|
|
14
|
+
console.log("nothing to pull (branch does not exist on remote)");
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const ref = localTrackingRef(opts.branch);
|
|
18
|
+
const sessions = await listBranchSessions(opts.repoRoot, ref);
|
|
19
|
+
if (sessions.length === 0) {
|
|
20
|
+
console.log("nothing to pull (no sessions on branch)");
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
const pulled = [];
|
|
24
|
+
const skipped = [];
|
|
25
|
+
for (const { id, meta } of sessions) {
|
|
26
|
+
const jsonl = await showFile(opts.repoRoot, ref, `${SESSIONS_DIR}/${id}.jsonl`);
|
|
27
|
+
if (jsonl === null) {
|
|
28
|
+
skipped.push(`${shortId(id)}: missing transcript blob`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Reconstruct the local cwd for this session: repo root + recorded subdir.
|
|
32
|
+
const rel = meta?.cwdRelativeToRepoRoot ?? "";
|
|
33
|
+
const localCwd = rel ? path.join(opts.repoRoot, rel) : opts.repoRoot;
|
|
34
|
+
const name = meta?.name ?? id;
|
|
35
|
+
// Conflict policy: newer local copy is kept unless --force.
|
|
36
|
+
const localFile = sessionFilePath(localCwd, id);
|
|
37
|
+
if (!opts.force && fs.existsSync(localFile)) {
|
|
38
|
+
const branchUpdated = meta?.updatedAt ?? "";
|
|
39
|
+
const localInfo = parseSession(fs.readFileSync(localFile, "utf8"), id);
|
|
40
|
+
const localUpdated = localInfo.updatedAt ?? "";
|
|
41
|
+
if (localUpdated && branchUpdated && localUpdated > branchUpdated) {
|
|
42
|
+
skipped.push(`${name} (${shortId(id)}): local copy is newer (use --force)`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Remap the structural cwd from the author's path to ours.
|
|
47
|
+
const remapped = meta?.originalCwd
|
|
48
|
+
? remapCwd(jsonl, meta.originalCwd, localCwd)
|
|
49
|
+
: jsonl;
|
|
50
|
+
const dir = projectDirForCwd(localCwd);
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(localFile, remapped);
|
|
53
|
+
pulled.push(`${name} (${shortId(id)})`);
|
|
54
|
+
}
|
|
55
|
+
if (pulled.length) {
|
|
56
|
+
console.log(`Pulled ${pulled.length} session(s):`);
|
|
57
|
+
for (const p of pulled)
|
|
58
|
+
console.log(` + ${p}`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.log("Pulled 0 sessions.");
|
|
62
|
+
}
|
|
63
|
+
if (skipped.length) {
|
|
64
|
+
console.log(`Skipped ${skipped.length}:`);
|
|
65
|
+
for (const s of skipped)
|
|
66
|
+
console.log(` - ${s}`);
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { SESSIONS_DIR } from "../constants.js";
|
|
2
|
+
import { applyToOrphanBranch, assertRemote, fetchBranch, localTrackingRef, showFile, validateBranchName, } from "../git.js";
|
|
3
|
+
import { listBranchSessionIds } from "../branch-sessions.js";
|
|
4
|
+
import { discoverLocalSessions } from "../discover.js";
|
|
5
|
+
import { buildMeta, gitAuthor, serializeMeta } from "../meta.js";
|
|
6
|
+
import { resolveTarget, shortId } from "../match.js";
|
|
7
|
+
export async function push(opts) {
|
|
8
|
+
await validateBranchName(opts.repoRoot, opts.branch);
|
|
9
|
+
await assertRemote(opts.repoRoot, opts.remote);
|
|
10
|
+
let sessions = discoverLocalSessions(opts.repoRoot);
|
|
11
|
+
if (sessions.length === 0) {
|
|
12
|
+
console.log("nothing to push (no local sessions found for this repo)");
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
// Apply positional id/name filters if any were given.
|
|
16
|
+
if (opts.filters.length > 0) {
|
|
17
|
+
const candidates = sessions.map((s) => ({
|
|
18
|
+
id: s.info.id,
|
|
19
|
+
name: s.info.displayName,
|
|
20
|
+
session: s,
|
|
21
|
+
}));
|
|
22
|
+
const selected = new Map();
|
|
23
|
+
for (const f of opts.filters) {
|
|
24
|
+
const res = resolveTarget(f, candidates);
|
|
25
|
+
if (res.kind === "match") {
|
|
26
|
+
selected.set(res.item.id, res.item.session);
|
|
27
|
+
}
|
|
28
|
+
else if (res.kind === "ambiguous") {
|
|
29
|
+
console.error(`"${f}" is ambiguous; matches:`);
|
|
30
|
+
for (const c of res.items)
|
|
31
|
+
console.error(` ${shortId(c.id)} ${c.name}`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.error(`"${f}" matched no local session for this repo`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
sessions = [...selected.values()];
|
|
40
|
+
}
|
|
41
|
+
// Pre-compute what already exists on the branch so we can label each session
|
|
42
|
+
// as added / updated / unchanged by comparing the transcript blob.
|
|
43
|
+
await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
|
|
44
|
+
const ref = localTrackingRef(opts.branch);
|
|
45
|
+
const existing = new Set(await listBranchSessionIds(opts.repoRoot, ref).catch(() => []));
|
|
46
|
+
const author = await gitAuthor(opts.repoRoot);
|
|
47
|
+
const added = [];
|
|
48
|
+
const updated = [];
|
|
49
|
+
let unchanged = 0;
|
|
50
|
+
for (const s of sessions) {
|
|
51
|
+
const label = `${s.info.displayName} (${shortId(s.info.id)})`;
|
|
52
|
+
if (!existing.has(s.info.id)) {
|
|
53
|
+
added.push(label);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const prev = await showFile(opts.repoRoot, ref, `${SESSIONS_DIR}/${s.info.id}.jsonl`);
|
|
57
|
+
if (prev === s.content)
|
|
58
|
+
unchanged++;
|
|
59
|
+
else
|
|
60
|
+
updated.push(label);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const result = await applyToOrphanBranch(opts.repoRoot, opts.remote, opts.branch, buildCommitMessage(added.length + updated.length), async (b) => {
|
|
64
|
+
for (const s of sessions) {
|
|
65
|
+
const meta = buildMeta(s.info, opts.repoRoot, author);
|
|
66
|
+
await b.addBlob(`${SESSIONS_DIR}/${s.info.id}.jsonl`, s.content);
|
|
67
|
+
await b.addBlob(`${SESSIONS_DIR}/${s.info.id}.meta.json`, serializeMeta(meta));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
if (!result.changed) {
|
|
71
|
+
console.log(`Already up to date — ${unchanged} session(s) unchanged.`);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
if (added.length) {
|
|
75
|
+
console.log(`Added ${added.length}:`);
|
|
76
|
+
for (const a of added)
|
|
77
|
+
console.log(` + ${a}`);
|
|
78
|
+
}
|
|
79
|
+
if (updated.length) {
|
|
80
|
+
console.log(`Updated ${updated.length}:`);
|
|
81
|
+
for (const u of updated)
|
|
82
|
+
console.log(` ~ ${u}`);
|
|
83
|
+
}
|
|
84
|
+
if (unchanged)
|
|
85
|
+
console.log(`(${unchanged} unchanged)`);
|
|
86
|
+
console.log(`Pushed to ${opts.remote} @ccgs/${opts.branch}.`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
function buildCommitMessage(n) {
|
|
90
|
+
return `ccgs: push ${n} session(s)`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the package + command identity, so renaming is a
|
|
3
|
+
* one-line change.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* npm package name. The bare name `ccgs` is blocked by npm's name-similarity
|
|
7
|
+
* filter, so the package ships as `claude-git-sessions`; the installed command is still
|
|
8
|
+
* `ccgs` (see COMMAND_NAME). Both are one-line changes if either is renamed.
|
|
9
|
+
*/
|
|
10
|
+
export const PACKAGE_NAME = "claude-git-sessions";
|
|
11
|
+
export const COMMAND_NAME = "ccgs";
|
|
12
|
+
/** Orphan branches are namespaced as `@ccgs/<name>`. */
|
|
13
|
+
export const BRANCH_PREFIX = "@ccgs/";
|
|
14
|
+
export const DEFAULT_BRANCH_NAME = "default";
|
|
15
|
+
export const DEFAULT_REMOTE = "origin";
|
|
16
|
+
/** Path prefix used for session blobs on the orphan branch. */
|
|
17
|
+
export const SESSIONS_DIR = "sessions";
|
|
18
|
+
/** Max length of an auto-derived display name. */
|
|
19
|
+
export const DISPLAY_NAME_MAX = 80;
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { projectsDir } from "./paths.js";
|
|
4
|
+
import { parseSession } from "./session.js";
|
|
5
|
+
/** Is `child` the same as, or nested under, `parent`? */
|
|
6
|
+
function isWithin(parent, child) {
|
|
7
|
+
if (child === parent)
|
|
8
|
+
return true;
|
|
9
|
+
const rel = path.relative(parent, child);
|
|
10
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Find local Claude Code sessions belonging to `repoRoot`.
|
|
14
|
+
*
|
|
15
|
+
* Sessions launched at the repo root live under the repo-root slug, but a
|
|
16
|
+
* session launched in a subdirectory lives under a *different* slug. So rather
|
|
17
|
+
* than rely on the slug alone, we scan every project directory, read each
|
|
18
|
+
* transcript, and keep the ones whose recorded `cwd` is the repo root or nested
|
|
19
|
+
* within it. The recorded cwd also gives us `cwdRelativeToRepoRoot` for free.
|
|
20
|
+
*/
|
|
21
|
+
export function discoverLocalSessions(repoRoot) {
|
|
22
|
+
const root = projectsDir();
|
|
23
|
+
let dirs;
|
|
24
|
+
try {
|
|
25
|
+
dirs = fs.readdirSync(root, { withFileTypes: true });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return []; // no ~/.claude/projects yet
|
|
29
|
+
}
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const dir of dirs) {
|
|
32
|
+
if (!dir.isDirectory())
|
|
33
|
+
continue;
|
|
34
|
+
const dirPath = path.join(root, dir.name);
|
|
35
|
+
let files;
|
|
36
|
+
try {
|
|
37
|
+
files = fs.readdirSync(dirPath);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
for (const f of files) {
|
|
43
|
+
if (!f.endsWith(".jsonl"))
|
|
44
|
+
continue;
|
|
45
|
+
const file = path.join(dirPath, f);
|
|
46
|
+
let content;
|
|
47
|
+
try {
|
|
48
|
+
content = fs.readFileSync(file, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const fallbackId = f.replace(/\.jsonl$/, "");
|
|
54
|
+
const info = parseSession(content, fallbackId);
|
|
55
|
+
if (info.cwd && isWithin(repoRoot, info.cwd)) {
|
|
56
|
+
found.push({ info, file, content });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return found;
|
|
61
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { BRANCH_PREFIX, SESSIONS_DIR } from "./constants.js";
|
|
6
|
+
export class GitError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
stderr;
|
|
9
|
+
constructor(message, code, stderr) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.stderr = stderr;
|
|
13
|
+
this.name = "GitError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run git with an explicit argv (never a shell string) so no user input is ever
|
|
18
|
+
* interpolated into a shell. stdout is captured as a Buffer then decoded utf-8.
|
|
19
|
+
*/
|
|
20
|
+
export function git(args, opts = {}) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const child = spawn("git", args, {
|
|
23
|
+
cwd: opts.cwd,
|
|
24
|
+
env: opts.env ?? process.env,
|
|
25
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
const out = [];
|
|
28
|
+
const err = [];
|
|
29
|
+
child.stdout.on("data", (d) => out.push(d));
|
|
30
|
+
child.stderr.on("data", (d) => err.push(d));
|
|
31
|
+
child.on("error", (e) => reject(e));
|
|
32
|
+
child.on("close", (code) => {
|
|
33
|
+
const result = {
|
|
34
|
+
code: code ?? 0,
|
|
35
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
36
|
+
stderr: Buffer.concat(err).toString("utf8"),
|
|
37
|
+
};
|
|
38
|
+
if (result.code !== 0 && !opts.allowFail) {
|
|
39
|
+
reject(new GitError(`git ${args.join(" ")} failed (exit ${result.code}): ${result.stderr.trim()}`, result.code, result.stderr));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
resolve(result);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
if (opts.input !== undefined)
|
|
46
|
+
child.stdin.end(opts.input);
|
|
47
|
+
else
|
|
48
|
+
child.stdin.end();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Convenience: return trimmed stdout, throwing on failure unless allowFail. */
|
|
52
|
+
export async function gitOut(args, opts = {}) {
|
|
53
|
+
const r = await git(args, opts);
|
|
54
|
+
return r.stdout;
|
|
55
|
+
}
|
|
56
|
+
/** Absolute repo root, or throw a clear error if not inside a git repo. */
|
|
57
|
+
export async function repoRoot() {
|
|
58
|
+
const r = await git(["rev-parse", "--show-toplevel"], { allowFail: true });
|
|
59
|
+
if (r.code !== 0) {
|
|
60
|
+
throw new Error("not inside a git repository (run ccgs from within your repo)");
|
|
61
|
+
}
|
|
62
|
+
return r.stdout.trim();
|
|
63
|
+
}
|
|
64
|
+
/** Full `@ccgs/<name>` branch name. */
|
|
65
|
+
export function branchName(name) {
|
|
66
|
+
return `${BRANCH_PREFIX}${name}`;
|
|
67
|
+
}
|
|
68
|
+
/** Local tracking ref we fetch the orphan branch into (kept out of refs/heads). */
|
|
69
|
+
export function localTrackingRef(name) {
|
|
70
|
+
return `refs/ccgs/${name}`;
|
|
71
|
+
}
|
|
72
|
+
/** Fully-qualified remote head ref we push to / fetch from. */
|
|
73
|
+
export function remoteHeadRef(name) {
|
|
74
|
+
return `refs/heads/${branchName(name)}`;
|
|
75
|
+
}
|
|
76
|
+
/** Validate the branch name with `git check-ref-format`; throw if invalid. */
|
|
77
|
+
export async function validateBranchName(repo, name) {
|
|
78
|
+
const ref = remoteHeadRef(name);
|
|
79
|
+
const r = await git(["check-ref-format", ref], { cwd: repo, allowFail: true });
|
|
80
|
+
if (r.code !== 0) {
|
|
81
|
+
throw new Error(`invalid branch name "${name}": "${ref}" is not a valid git ref`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Error clearly if the named remote does not exist. */
|
|
85
|
+
export async function assertRemote(repo, remote) {
|
|
86
|
+
const r = await git(["remote"], { cwd: repo });
|
|
87
|
+
const remotes = r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
88
|
+
if (!remotes.includes(remote)) {
|
|
89
|
+
throw new Error(`remote "${remote}" not found (configured remotes: ${remotes.join(", ") || "none"})`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fetch the orphan branch from the remote into our local tracking ref.
|
|
94
|
+
* Returns true if it exists remotely, false if the remote ref is absent.
|
|
95
|
+
*/
|
|
96
|
+
export async function fetchBranch(repo, remote, name) {
|
|
97
|
+
const refspec = `+${remoteHeadRef(name)}:${localTrackingRef(name)}`;
|
|
98
|
+
const r = await git(["fetch", "--no-tags", remote, refspec], {
|
|
99
|
+
cwd: repo,
|
|
100
|
+
allowFail: true,
|
|
101
|
+
});
|
|
102
|
+
if (r.code === 0)
|
|
103
|
+
return true;
|
|
104
|
+
// git reports a missing branch as "couldn't find remote ref ...".
|
|
105
|
+
if (/couldn't find remote ref|couldn't find remote/i.test(r.stderr))
|
|
106
|
+
return false;
|
|
107
|
+
throw new GitError(`failed to fetch ${branchName(name)} from ${remote}: ${r.stderr.trim()}`, r.code, r.stderr);
|
|
108
|
+
}
|
|
109
|
+
/** SHA of the local tracking ref, or null if it does not exist. */
|
|
110
|
+
export async function trackingTip(repo, name) {
|
|
111
|
+
const r = await git(["rev-parse", "--verify", "--quiet", localTrackingRef(name)], {
|
|
112
|
+
cwd: repo,
|
|
113
|
+
allowFail: true,
|
|
114
|
+
});
|
|
115
|
+
const sha = r.stdout.trim();
|
|
116
|
+
return sha || null;
|
|
117
|
+
}
|
|
118
|
+
/** List `sessions/*` paths present on a tree-ish ref. */
|
|
119
|
+
export async function lsSessions(repo, ref) {
|
|
120
|
+
const r = await git(["ls-tree", "-r", "--name-only", ref], { cwd: repo, allowFail: true });
|
|
121
|
+
if (r.code !== 0)
|
|
122
|
+
return [];
|
|
123
|
+
return r.stdout
|
|
124
|
+
.split("\n")
|
|
125
|
+
.map((s) => s.trim())
|
|
126
|
+
.filter((p) => p.startsWith(`${SESSIONS_DIR}/`));
|
|
127
|
+
}
|
|
128
|
+
/** Read a blob at `ref:path` as utf-8 text, or null if it does not exist. */
|
|
129
|
+
export async function showFile(repo, ref, filePath) {
|
|
130
|
+
const r = await git(["show", `${ref}:${filePath}`], { cwd: repo, allowFail: true });
|
|
131
|
+
if (r.code !== 0)
|
|
132
|
+
return null;
|
|
133
|
+
return r.stdout;
|
|
134
|
+
}
|
|
135
|
+
const NONFF = /non-fast-forward|fetch first|\[rejected\]|cannot lock ref|failed to update ref|stale info/i;
|
|
136
|
+
/**
|
|
137
|
+
* Build a new commit on the orphan branch and push it, WITHOUT touching the
|
|
138
|
+
* user's working tree, index or current branch, and tolerating a dirty tree.
|
|
139
|
+
*
|
|
140
|
+
* Mechanics (pure plumbing):
|
|
141
|
+
* - work against a private temp index via GIT_INDEX_FILE
|
|
142
|
+
* - seed it from the current branch tip (or empty, for a true orphan root)
|
|
143
|
+
* - let `apply` hash-object/update-index the session blobs
|
|
144
|
+
* - write-tree -> commit-tree (-p parent, omitted => orphan root)
|
|
145
|
+
* - push <commit>:refs/heads/@ccgs/<name>
|
|
146
|
+
* On a non-fast-forward rejection it re-fetches the tip and replays, a few times.
|
|
147
|
+
*/
|
|
148
|
+
export async function applyToOrphanBranch(repo, remote, name, message, apply, maxRetries = 5) {
|
|
149
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
150
|
+
const exists = await fetchBranch(repo, remote, name);
|
|
151
|
+
const parent = exists ? await trackingTip(repo, name) : null;
|
|
152
|
+
const indexFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ccgs-idx-")), "index");
|
|
153
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexFile };
|
|
154
|
+
try {
|
|
155
|
+
// Seed the temp index from the parent tree (orphan root starts empty).
|
|
156
|
+
if (parent) {
|
|
157
|
+
await git(["read-tree", parent], { cwd: repo, env });
|
|
158
|
+
}
|
|
159
|
+
const builder = {
|
|
160
|
+
async addBlob(gitPath, content) {
|
|
161
|
+
const sha = (await gitOut(["hash-object", "-w", "--stdin"], { cwd: repo, input: content })).trim();
|
|
162
|
+
await git(["update-index", "--add", "--cacheinfo", `100644,${sha},${gitPath}`], { cwd: repo, env });
|
|
163
|
+
},
|
|
164
|
+
async removeBlob(gitPath) {
|
|
165
|
+
await git(["update-index", "--force-remove", gitPath], { cwd: repo, env, allowFail: true });
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
await apply(builder);
|
|
169
|
+
const tree = (await gitOut(["write-tree"], { cwd: repo, env })).trim();
|
|
170
|
+
// No-op short-circuit: identical to parent tree -> nothing to push.
|
|
171
|
+
if (parent) {
|
|
172
|
+
const parentTree = (await gitOut(["rev-parse", `${parent}^{tree}`], { cwd: repo })).trim();
|
|
173
|
+
if (tree === parentTree)
|
|
174
|
+
return { changed: false };
|
|
175
|
+
}
|
|
176
|
+
const commitArgs = ["commit-tree", tree];
|
|
177
|
+
if (parent)
|
|
178
|
+
commitArgs.push("-p", parent);
|
|
179
|
+
const commit = (await gitOut(commitArgs, { cwd: repo, input: message })).trim();
|
|
180
|
+
const push = await git(["push", remote, `${commit}:${remoteHeadRef(name)}`], { cwd: repo, allowFail: true });
|
|
181
|
+
if (push.code === 0)
|
|
182
|
+
return { changed: true, commit };
|
|
183
|
+
if (NONFF.test(push.stderr) && attempt < maxRetries - 1) {
|
|
184
|
+
continue; // re-fetch tip and replay on top
|
|
185
|
+
}
|
|
186
|
+
throw new GitError(`failed to push ${branchName(name)} to ${remote}: ${push.stderr.trim()}`, push.code, push.stderr);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
// Clean up the temp index dir regardless of outcome.
|
|
190
|
+
fs.rmSync(path.dirname(indexFile), { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`could not push ${branchName(name)} after ${maxRetries} attempts (concurrent updates?)`);
|
|
194
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { COMMAND_NAME, DEFAULT_BRANCH_NAME, DEFAULT_REMOTE, PACKAGE_NAME, } from "./constants.js";
|
|
4
|
+
import { repoRoot } from "./git.js";
|
|
5
|
+
import { pull } from "./commands/pull.js";
|
|
6
|
+
import { push } from "./commands/push.js";
|
|
7
|
+
import { deleteSession } from "./commands/delete.js";
|
|
8
|
+
// Kept in sync with package.json at publish time; hardcoded to avoid a JSON
|
|
9
|
+
// import assertion just for --version.
|
|
10
|
+
const VERSION = "0.1.0";
|
|
11
|
+
function globals(cmd) {
|
|
12
|
+
const opts = cmd.optsWithGlobals();
|
|
13
|
+
return {
|
|
14
|
+
branch: opts.branch ?? DEFAULT_BRANCH_NAME,
|
|
15
|
+
remote: opts.remote ?? DEFAULT_REMOTE,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
async function run(fn) {
|
|
19
|
+
try {
|
|
20
|
+
process.exitCode = await fn();
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
console.error(`${COMMAND_NAME}: ${err instanceof Error ? err.message : String(err)}`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const program = new Command();
|
|
28
|
+
program
|
|
29
|
+
.name(COMMAND_NAME)
|
|
30
|
+
.description(`${PACKAGE_NAME} — share Claude Code sessions through an orphan git branch`)
|
|
31
|
+
.version(VERSION, "-v, --version")
|
|
32
|
+
.option("-b, --branch <name>", "session set name (branch @ccgs/<name>)", DEFAULT_BRANCH_NAME)
|
|
33
|
+
.option("--remote <remote>", "git remote to use", DEFAULT_REMOTE);
|
|
34
|
+
program
|
|
35
|
+
.command("pull")
|
|
36
|
+
.description("fetch shared sessions and place them where Claude Code can resume them")
|
|
37
|
+
.option("--force", "overwrite local copies even if they are newer", false)
|
|
38
|
+
.action(async (localOpts, cmd) => {
|
|
39
|
+
const g = globals(cmd);
|
|
40
|
+
await run(async () => {
|
|
41
|
+
const root = await repoRoot();
|
|
42
|
+
return pull({ repoRoot: root, remote: g.remote, branch: g.branch, force: localOpts.force });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
program
|
|
46
|
+
.command("push")
|
|
47
|
+
.description("publish this repo's local sessions to the orphan branch")
|
|
48
|
+
.argument("[targets...]", "optional session ids/names to push (default: all)")
|
|
49
|
+
.action(async (targets, _localOpts, cmd) => {
|
|
50
|
+
const g = globals(cmd);
|
|
51
|
+
await run(async () => {
|
|
52
|
+
const root = await repoRoot();
|
|
53
|
+
return push({ repoRoot: root, remote: g.remote, branch: g.branch, filters: targets });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
program
|
|
57
|
+
.command("delete")
|
|
58
|
+
.description("remove a session from the shared orphan branch")
|
|
59
|
+
.argument("<target>", "session id, unambiguous id prefix, or unique name")
|
|
60
|
+
.option("-y, --yes", "skip the interactive confirmation", false)
|
|
61
|
+
.option("--local", "also remove the local copy under ~/.claude/projects", false)
|
|
62
|
+
.action(async (target, localOpts, cmd) => {
|
|
63
|
+
const g = globals(cmd);
|
|
64
|
+
await run(async () => {
|
|
65
|
+
const root = await repoRoot();
|
|
66
|
+
return deleteSession({
|
|
67
|
+
repoRoot: root,
|
|
68
|
+
remote: g.remote,
|
|
69
|
+
branch: g.branch,
|
|
70
|
+
target,
|
|
71
|
+
yes: localOpts.yes,
|
|
72
|
+
local: localOpts.local,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
program.parseAsync(process.argv);
|
package/dist/match.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function resolveTarget(query, candidates) {
|
|
2
|
+
const q = query.trim();
|
|
3
|
+
// 1. Exact id wins outright.
|
|
4
|
+
const exactId = candidates.filter((c) => c.id === q);
|
|
5
|
+
if (exactId.length === 1)
|
|
6
|
+
return { kind: "match", item: exactId[0] };
|
|
7
|
+
// 2. Exact (case-insensitive) display-name match.
|
|
8
|
+
const exactName = candidates.filter((c) => c.name.toLowerCase() === q.toLowerCase());
|
|
9
|
+
if (exactName.length === 1)
|
|
10
|
+
return { kind: "match", item: exactName[0] };
|
|
11
|
+
if (exactName.length > 1)
|
|
12
|
+
return { kind: "ambiguous", items: exactName };
|
|
13
|
+
// 3. Git-style unambiguous id prefix (only for hex-ish queries).
|
|
14
|
+
if (/^[0-9a-fA-F-]+$/.test(q) && q.length >= 4) {
|
|
15
|
+
const byPrefix = candidates.filter((c) => c.id.startsWith(q.toLowerCase()));
|
|
16
|
+
if (byPrefix.length === 1)
|
|
17
|
+
return { kind: "match", item: byPrefix[0] };
|
|
18
|
+
if (byPrefix.length > 1)
|
|
19
|
+
return { kind: "ambiguous", items: byPrefix };
|
|
20
|
+
}
|
|
21
|
+
// 4. Fall back to a case-insensitive name substring.
|
|
22
|
+
const bySubstr = candidates.filter((c) => c.name.toLowerCase().includes(q.toLowerCase()));
|
|
23
|
+
if (bySubstr.length === 1)
|
|
24
|
+
return { kind: "match", item: bySubstr[0] };
|
|
25
|
+
if (bySubstr.length > 1)
|
|
26
|
+
return { kind: "ambiguous", items: bySubstr };
|
|
27
|
+
return { kind: "none" };
|
|
28
|
+
}
|
|
29
|
+
/** Short 8-char id, git-style, for display. */
|
|
30
|
+
export function shortId(id) {
|
|
31
|
+
return id.slice(0, 8);
|
|
32
|
+
}
|
package/dist/meta.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gitOut } from "./git.js";
|
|
4
|
+
/** "Name <email>" from git config, best-effort. */
|
|
5
|
+
export async function gitAuthor(repoRoot) {
|
|
6
|
+
const name = (await gitOut(["config", "user.name"], { cwd: repoRoot, allowFail: true })).trim();
|
|
7
|
+
const email = (await gitOut(["config", "user.email"], { cwd: repoRoot, allowFail: true })).trim();
|
|
8
|
+
if (name && email)
|
|
9
|
+
return `${name} <${email}>`;
|
|
10
|
+
if (name)
|
|
11
|
+
return name;
|
|
12
|
+
if (email)
|
|
13
|
+
return email;
|
|
14
|
+
return "unknown";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the sidecar metadata for a local session being pushed.
|
|
18
|
+
* `cwdRelativeToRepoRoot` is derived from the session's recorded cwd relative
|
|
19
|
+
* to the local repo root (POSIX-style, "" when at the root).
|
|
20
|
+
*/
|
|
21
|
+
export function buildMeta(info, repoRoot, author) {
|
|
22
|
+
const originalCwd = info.cwd ?? repoRoot;
|
|
23
|
+
let rel = path.relative(repoRoot, originalCwd);
|
|
24
|
+
if (rel === "" || rel === ".")
|
|
25
|
+
rel = "";
|
|
26
|
+
// Normalize to forward slashes so it round-trips across platforms.
|
|
27
|
+
rel = rel.split(path.sep).join("/");
|
|
28
|
+
return {
|
|
29
|
+
id: info.id,
|
|
30
|
+
name: info.displayName,
|
|
31
|
+
originalCwd,
|
|
32
|
+
cwdRelativeToRepoRoot: rel,
|
|
33
|
+
author,
|
|
34
|
+
machine: os.hostname(),
|
|
35
|
+
messageCount: info.messageCount,
|
|
36
|
+
updatedAt: info.updatedAt ?? new Date().toISOString(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function serializeMeta(meta) {
|
|
40
|
+
return JSON.stringify(meta, null, 2) + "\n";
|
|
41
|
+
}
|
|
42
|
+
export function parseMeta(raw) {
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToProjectSlug } from "./slug.js";
|
|
4
|
+
/**
|
|
5
|
+
* Root of the Claude Code config, honoring `CLAUDE_CONFIG_DIR` and falling back
|
|
6
|
+
* to `~/.claude`.
|
|
7
|
+
*/
|
|
8
|
+
export function claudeConfigDir() {
|
|
9
|
+
const override = process.env.CLAUDE_CONFIG_DIR;
|
|
10
|
+
if (override && override.trim() !== "")
|
|
11
|
+
return override;
|
|
12
|
+
return path.join(os.homedir(), ".claude");
|
|
13
|
+
}
|
|
14
|
+
/** `<config>/projects` — the directory holding one slug subdir per project. */
|
|
15
|
+
export function projectsDir() {
|
|
16
|
+
return path.join(claudeConfigDir(), "projects");
|
|
17
|
+
}
|
|
18
|
+
/** Absolute project directory Claude Code uses for sessions launched in `absCwd`. */
|
|
19
|
+
export function projectDirForCwd(absCwd) {
|
|
20
|
+
return path.join(projectsDir(), pathToProjectSlug(absCwd));
|
|
21
|
+
}
|
|
22
|
+
/** Where a session `.jsonl` lives locally for a given cwd + id. */
|
|
23
|
+
export function sessionFilePath(absCwd, sessionId) {
|
|
24
|
+
return path.join(projectDirForCwd(absCwd), `${sessionId}.jsonl`);
|
|
25
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { DISPLAY_NAME_MAX } from "./constants.js";
|
|
2
|
+
function safeParse(line) {
|
|
3
|
+
if (!line)
|
|
4
|
+
return null;
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(line);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Collapse whitespace and truncate for a tidy one-line display name. */
|
|
13
|
+
export function truncateName(s, max = DISPLAY_NAME_MAX) {
|
|
14
|
+
const clean = s.replace(/\s+/g, " ").trim();
|
|
15
|
+
if (clean.length <= max)
|
|
16
|
+
return clean;
|
|
17
|
+
return clean.slice(0, max - 1).trimEnd() + "…";
|
|
18
|
+
}
|
|
19
|
+
/** Pull plain text out of a user message `content` (string or block array). */
|
|
20
|
+
function userMessageText(content) {
|
|
21
|
+
if (typeof content === "string")
|
|
22
|
+
return content;
|
|
23
|
+
if (Array.isArray(content)) {
|
|
24
|
+
for (const block of content) {
|
|
25
|
+
if (block && typeof block === "object" && typeof block.text === "string") {
|
|
26
|
+
return block.text;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse a session `.jsonl` body into a {@link SessionInfo}. `fallbackId` (the
|
|
34
|
+
* filename UUID) is used when no line carries a `sessionId`.
|
|
35
|
+
*/
|
|
36
|
+
export function parseSession(content, fallbackId) {
|
|
37
|
+
const lines = content.split("\n");
|
|
38
|
+
let id = null;
|
|
39
|
+
let cwd = null;
|
|
40
|
+
let title = null;
|
|
41
|
+
let summary = null;
|
|
42
|
+
let firstUserText = null;
|
|
43
|
+
let messageCount = 0;
|
|
44
|
+
let updatedAt = null;
|
|
45
|
+
for (const raw of lines) {
|
|
46
|
+
const obj = safeParse(raw);
|
|
47
|
+
if (!obj)
|
|
48
|
+
continue;
|
|
49
|
+
if (!id && typeof obj.sessionId === "string")
|
|
50
|
+
id = obj.sessionId;
|
|
51
|
+
// Track the most recent cwd we see (later lines win).
|
|
52
|
+
if (typeof obj.cwd === "string" && obj.cwd)
|
|
53
|
+
cwd = obj.cwd;
|
|
54
|
+
if (typeof obj.timestamp === "string") {
|
|
55
|
+
if (!updatedAt || obj.timestamp > updatedAt)
|
|
56
|
+
updatedAt = obj.timestamp;
|
|
57
|
+
}
|
|
58
|
+
if (!title) {
|
|
59
|
+
if (typeof obj.aiTitle === "string" && obj.aiTitle.trim())
|
|
60
|
+
title = obj.aiTitle;
|
|
61
|
+
else if (obj.type === "summary" && typeof obj.summary === "string" && obj.summary.trim())
|
|
62
|
+
summary = obj.summary;
|
|
63
|
+
else if (typeof obj.title === "string" && obj.title.trim())
|
|
64
|
+
title = obj.title;
|
|
65
|
+
}
|
|
66
|
+
if (obj.type === "user" || obj.type === "assistant")
|
|
67
|
+
messageCount++;
|
|
68
|
+
if (firstUserText === null && obj.type === "user" && obj.message) {
|
|
69
|
+
const t = userMessageText(obj.message.content);
|
|
70
|
+
if (t && t.trim())
|
|
71
|
+
firstUserText = t;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Display name resolution order: title -> summary -> first user msg -> id.
|
|
75
|
+
const resolvedId = id ?? fallbackId;
|
|
76
|
+
let displayName;
|
|
77
|
+
if (title)
|
|
78
|
+
displayName = truncateName(title);
|
|
79
|
+
else if (summary)
|
|
80
|
+
displayName = truncateName(summary);
|
|
81
|
+
else if (firstUserText)
|
|
82
|
+
displayName = truncateName(firstUserText);
|
|
83
|
+
else
|
|
84
|
+
displayName = resolvedId;
|
|
85
|
+
return { id: resolvedId, cwd, displayName, messageCount, updatedAt };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Rewrite the structural `cwd` field from `fromCwd` to `toCwd` on every line
|
|
89
|
+
* whose parsed `cwd` exactly equals `fromCwd`.
|
|
90
|
+
*
|
|
91
|
+
* We deliberately do a TARGETED replacement of just the `"cwd":"..."` token
|
|
92
|
+
* (located by JSON-encoding the values) rather than a blind find-and-replace or
|
|
93
|
+
* a full re-serialize: this preserves every other byte of the transcript
|
|
94
|
+
* exactly, avoiding any risk of corrupting message/tool-output content that may
|
|
95
|
+
* happen to contain the same path string.
|
|
96
|
+
*/
|
|
97
|
+
export function remapCwd(content, fromCwd, toCwd) {
|
|
98
|
+
if (fromCwd === toCwd)
|
|
99
|
+
return content;
|
|
100
|
+
const fromToken = `"cwd":${JSON.stringify(fromCwd)}`;
|
|
101
|
+
const toToken = `"cwd":${JSON.stringify(toCwd)}`;
|
|
102
|
+
return content
|
|
103
|
+
.split("\n")
|
|
104
|
+
.map((line) => {
|
|
105
|
+
const obj = safeParse(line);
|
|
106
|
+
if (obj && typeof obj.cwd === "string" && obj.cwd === fromCwd) {
|
|
107
|
+
// Replace only the first occurrence (the structural field); any path in
|
|
108
|
+
// free-text content is left untouched.
|
|
109
|
+
return line.replace(fromToken, toToken);
|
|
110
|
+
}
|
|
111
|
+
return line;
|
|
112
|
+
})
|
|
113
|
+
.join("\n");
|
|
114
|
+
}
|
package/dist/slug.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an absolute filesystem path into the directory slug Claude Code uses
|
|
3
|
+
* under `~/.claude/projects/`.
|
|
4
|
+
*
|
|
5
|
+
* Verified empirically against a real `~/.claude/projects/` by comparing each
|
|
6
|
+
* project directory name to the `cwd` recorded inside its session `.jsonl`
|
|
7
|
+
* files. Illustrative examples:
|
|
8
|
+
*
|
|
9
|
+
* /home/user -> -home-user
|
|
10
|
+
* /home/user/code/api -> -home-user-code-api
|
|
11
|
+
* /home/user/code/my.app.dev -> -home-user-code-my-app-dev (dot -> '-')
|
|
12
|
+
* /home/user/code/web-client -> -home-user-code-web-client (existing '-' kept)
|
|
13
|
+
*
|
|
14
|
+
* Rule: replace every character that is NOT [A-Za-z0-9] with '-'. The leading
|
|
15
|
+
* '/' therefore becomes a leading '-'.
|
|
16
|
+
*
|
|
17
|
+
* IMPORTANT: this encoding is LOSSY — both '/' and '.' (and any other special
|
|
18
|
+
* character) map to '-', so it cannot be reversed back into a unique path.
|
|
19
|
+
* That is why ccgs only ever goes path -> slug, never slug -> path, and why the
|
|
20
|
+
* sidecar `meta.json` carries the real `originalCwd` separately.
|
|
21
|
+
*
|
|
22
|
+
* Keep this as the ONE place that encodes the convention, so it is trivial to
|
|
23
|
+
* fix if Claude Code ever changes it.
|
|
24
|
+
*/
|
|
25
|
+
export function pathToProjectSlug(absPath) {
|
|
26
|
+
return absPath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-git-sessions",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Share Claude Code sessions across a team through an orphan git branch in your existing repo.",
|
|
5
|
+
"keywords": ["claude", "claude-code", "git", "sessions", "cli"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ingram Technologies",
|
|
8
|
+
"homepage": "https://github.com/ingram-technologies/ccgs",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ingram-technologies/ccgs.git"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"ccgs": "dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
26
|
+
"dev": "tsc --watch",
|
|
27
|
+
"test": "node --test --import tsx test/*.test.ts",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"prepare": "npm run build",
|
|
30
|
+
"prepublishOnly": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"commander": "^12.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^20.14.0",
|
|
37
|
+
"tsx": "^4.16.0",
|
|
38
|
+
"typescript": "^5.5.0"
|
|
39
|
+
}
|
|
40
|
+
}
|