@claudemini/shit-cli 1.4.0 → 1.5.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 +126 -117
- package/lib/checkpoint.js +39 -32
- package/lib/checkpoints.js +3 -15
- package/lib/commit.js +7 -15
- package/lib/config.js +3 -1
- package/lib/disable.js +54 -18
- package/lib/doctor.js +17 -24
- package/lib/enable.js +24 -13
- package/lib/explain.js +43 -38
- package/lib/reset.js +8 -16
- package/lib/resume.js +32 -27
- package/lib/rewind.js +63 -38
- package/lib/session.js +6 -0
- package/lib/status.js +44 -19
- package/lib/summarize.js +2 -13
- package/package.json +21 -4
- package/.claude/settings.json +0 -81
- package/.claude/settings.local.json +0 -20
- package/COMPARISON.md +0 -92
- package/DESIGN_PHILOSOPHY.md +0 -138
- package/QUICKSTART.md +0 -109
package/lib/doctor.js
CHANGED
|
@@ -5,20 +5,10 @@
|
|
|
5
5
|
* Similar to 'entire doctor' - repairs corrupted state and cleans up
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync,
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { execSync } from 'child_process';
|
|
11
|
-
|
|
12
|
-
function findProjectRoot() {
|
|
13
|
-
let dir = process.cwd();
|
|
14
|
-
while (dir !== '/') {
|
|
15
|
-
if (existsSync(join(dir, '.git'))) {
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
dir = join(dir, '..');
|
|
19
|
-
}
|
|
20
|
-
throw new Error('Not in a git repository');
|
|
21
|
-
}
|
|
11
|
+
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
22
12
|
|
|
23
13
|
function git(cmd, cwd) {
|
|
24
14
|
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
@@ -33,7 +23,6 @@ function checkShitLogsStructure(projectRoot) {
|
|
|
33
23
|
type: 'missing_directory',
|
|
34
24
|
message: '.shit-logs directory not found',
|
|
35
25
|
fix: () => {
|
|
36
|
-
const { mkdirSync } = require('fs');
|
|
37
26
|
mkdirSync(shitLogsDir, { recursive: true });
|
|
38
27
|
return 'Created .shit-logs directory';
|
|
39
28
|
}
|
|
@@ -67,7 +56,6 @@ function checkShitLogsStructure(projectRoot) {
|
|
|
67
56
|
message: 'index.json is corrupted',
|
|
68
57
|
fix: () => {
|
|
69
58
|
const backup = `${indexFile}.backup.${Date.now()}`;
|
|
70
|
-
const { copyFileSync } = require('fs');
|
|
71
59
|
copyFileSync(indexFile, backup);
|
|
72
60
|
|
|
73
61
|
const initialIndex = {
|
|
@@ -98,7 +86,7 @@ function checkSessionDirectories(projectRoot) {
|
|
|
98
86
|
const entries = readdirSync(shitLogsDir);
|
|
99
87
|
const sessionDirs = entries.filter(name => {
|
|
100
88
|
const fullPath = join(shitLogsDir, name);
|
|
101
|
-
return statSync(fullPath).isDirectory() &&
|
|
89
|
+
return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
|
|
102
90
|
});
|
|
103
91
|
|
|
104
92
|
for (const sessionDir of sessionDirs) {
|
|
@@ -132,7 +120,6 @@ function checkSessionDirectories(projectRoot) {
|
|
|
132
120
|
sessionDir,
|
|
133
121
|
fix: () => {
|
|
134
122
|
const backup = `${stateFile}.backup.${Date.now()}`;
|
|
135
|
-
const { copyFileSync } = require('fs');
|
|
136
123
|
copyFileSync(stateFile, backup);
|
|
137
124
|
|
|
138
125
|
const defaultState = {
|
|
@@ -166,7 +153,7 @@ function checkOrphanedShadowBranches(projectRoot) {
|
|
|
166
153
|
const sessionDirs = existsSync(shitLogsDir) ?
|
|
167
154
|
readdirSync(shitLogsDir).filter(name => {
|
|
168
155
|
const fullPath = join(shitLogsDir, name);
|
|
169
|
-
return statSync(fullPath).isDirectory() &&
|
|
156
|
+
return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
|
|
170
157
|
}) : [];
|
|
171
158
|
|
|
172
159
|
for (const branch of branches) {
|
|
@@ -208,11 +195,12 @@ function checkStuckSessions(projectRoot) {
|
|
|
208
195
|
const entries = readdirSync(shitLogsDir);
|
|
209
196
|
const sessionDirs = entries.filter(name => {
|
|
210
197
|
const fullPath = join(shitLogsDir, name);
|
|
211
|
-
return statSync(fullPath).isDirectory() &&
|
|
198
|
+
return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
|
|
212
199
|
});
|
|
213
200
|
|
|
214
201
|
const now = new Date();
|
|
215
202
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
203
|
+
const endedHookTypes = new Set(['session-end', 'SessionEnd', 'stop', 'session_end', 'end']);
|
|
216
204
|
|
|
217
205
|
for (const sessionDir of sessionDirs) {
|
|
218
206
|
const sessionPath = join(shitLogsDir, sessionDir);
|
|
@@ -221,13 +209,18 @@ function checkStuckSessions(projectRoot) {
|
|
|
221
209
|
if (existsSync(stateFile)) {
|
|
222
210
|
try {
|
|
223
211
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
212
|
+
const lastActivity = new Date(state.last_time || state.start_time);
|
|
213
|
+
const hasValidLastActivity = !Number.isNaN(lastActivity.getTime());
|
|
214
|
+
const endedByHook = endedHookTypes.has(state.last_hook_type);
|
|
215
|
+
const hasShadowBranch = typeof state.shadow_branch === 'string' && state.shadow_branch.length > 0;
|
|
216
|
+
const hasCheckpoint = Array.isArray(state.checkpoints) && state.checkpoints.length > 0;
|
|
217
|
+
const consideredEnded = Boolean(state.end_time) || endedByHook || hasShadowBranch || hasCheckpoint;
|
|
218
|
+
|
|
219
|
+
// Stuck means inactive for >24h and not explicitly/implicitly ended.
|
|
220
|
+
if (!consideredEnded && hasValidLastActivity && lastActivity < oneDayAgo) {
|
|
228
221
|
issues.push({
|
|
229
222
|
type: 'stuck_session',
|
|
230
|
-
message: `Stuck session: ${sessionDir} (
|
|
223
|
+
message: `Stuck session: ${sessionDir} (last activity ${lastActivity.toLocaleString()})`,
|
|
231
224
|
sessionDir,
|
|
232
225
|
fix: () => {
|
|
233
226
|
state.end_time = new Date().toISOString();
|
|
@@ -248,7 +241,7 @@ function checkStuckSessions(projectRoot) {
|
|
|
248
241
|
|
|
249
242
|
export default async function doctor(args) {
|
|
250
243
|
try {
|
|
251
|
-
const projectRoot =
|
|
244
|
+
const projectRoot = getProjectRoot();
|
|
252
245
|
const autoFix = args.includes('--fix') || args.includes('--auto-fix');
|
|
253
246
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
254
247
|
|
package/lib/enable.js
CHANGED
|
@@ -9,17 +9,7 @@
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
|
-
|
|
13
|
-
function findProjectRoot() {
|
|
14
|
-
let dir = process.cwd();
|
|
15
|
-
while (dir !== '/') {
|
|
16
|
-
if (existsSync(join(dir, '.git'))) {
|
|
17
|
-
return dir;
|
|
18
|
-
}
|
|
19
|
-
dir = join(dir, '..');
|
|
20
|
-
}
|
|
21
|
-
throw new Error('Not in a git repository');
|
|
22
|
-
}
|
|
12
|
+
import { getProjectRoot } from './config.js';
|
|
23
13
|
|
|
24
14
|
// Agent-specific hook configurations
|
|
25
15
|
const AGENT_HOOKS = {
|
|
@@ -95,9 +85,30 @@ function setupAgentHooks(projectRoot, agentType) {
|
|
|
95
85
|
}
|
|
96
86
|
|
|
97
87
|
// Add shit-cli hooks
|
|
98
|
-
if (!settings.hooks)
|
|
88
|
+
if (!settings.hooks) {
|
|
89
|
+
settings.hooks = {};
|
|
90
|
+
}
|
|
99
91
|
|
|
100
92
|
for (const [hookName, command] of Object.entries(config.hooks)) {
|
|
93
|
+
if (agentType === 'claude-code') {
|
|
94
|
+
const current = settings.hooks[hookName];
|
|
95
|
+
const existing = Array.isArray(current)
|
|
96
|
+
? current
|
|
97
|
+
: (typeof current === 'string'
|
|
98
|
+
? [{ matcher: '', hooks: [{ type: 'command', command: current }] }]
|
|
99
|
+
: []);
|
|
100
|
+
const alreadyExists = existing.some(entry =>
|
|
101
|
+
Array.isArray(entry?.hooks) &&
|
|
102
|
+
entry.hooks.some(hook => typeof hook?.command === 'string' && hook.command.includes('shit log'))
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (!alreadyExists) {
|
|
106
|
+
existing.push({ matcher: '', hooks: [{ type: 'command', command }] });
|
|
107
|
+
}
|
|
108
|
+
settings.hooks[hookName] = existing;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
101
112
|
settings.hooks[hookName] = command;
|
|
102
113
|
}
|
|
103
114
|
|
|
@@ -164,7 +175,7 @@ function initializeShitLogs(projectRoot) {
|
|
|
164
175
|
|
|
165
176
|
export default async function enable(args) {
|
|
166
177
|
try {
|
|
167
|
-
const projectRoot =
|
|
178
|
+
const projectRoot = getProjectRoot();
|
|
168
179
|
|
|
169
180
|
// Parse arguments
|
|
170
181
|
const agents = [];
|
package/lib/explain.js
CHANGED
|
@@ -5,19 +5,25 @@
|
|
|
5
5
|
* Provides human-readable explanation of what happened in a session
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync } from 'fs';
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
|
-
import {
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
11
12
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
function git(args, cwd, ignoreError = false) {
|
|
14
|
+
try {
|
|
15
|
+
return execFileSync('git', args, {
|
|
16
|
+
cwd,
|
|
17
|
+
encoding: 'utf-8',
|
|
18
|
+
timeout: 10000,
|
|
19
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
20
|
+
}).trim();
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (ignoreError) {
|
|
23
|
+
return null;
|
|
17
24
|
}
|
|
18
|
-
|
|
25
|
+
throw error;
|
|
19
26
|
}
|
|
20
|
-
throw new Error('Not in a git repository');
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
function getSessionData(sessionId, projectRoot) {
|
|
@@ -41,7 +47,6 @@ function getSessionData(sessionId, projectRoot) {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
function explainFromSummary(summaryText) {
|
|
44
|
-
const lines = summaryText.split('\n');
|
|
45
50
|
const explanation = [];
|
|
46
51
|
|
|
47
52
|
// Extract key information
|
|
@@ -113,23 +118,20 @@ function explainFromSummary(summaryText) {
|
|
|
113
118
|
function explainFromCommit(commitSha, projectRoot) {
|
|
114
119
|
try {
|
|
115
120
|
// Try to find associated checkpoint
|
|
116
|
-
const checkpoints =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
const checkpoints = (git(['branch', '--list', 'shit/checkpoints/v1/*'], projectRoot, true) || '')
|
|
122
|
+
.split('\n')
|
|
123
|
+
.map(line => line.trim().replace(/^\*?\s*/, ''))
|
|
124
|
+
.filter(Boolean);
|
|
120
125
|
|
|
121
126
|
for (const branch of checkpoints) {
|
|
122
127
|
try {
|
|
123
|
-
const log =
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
128
|
+
const log = git(['log', branch, '--oneline', '-1'], projectRoot, true);
|
|
129
|
+
if (!log) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
127
132
|
|
|
128
133
|
if (log.includes(commitSha.slice(0, 12))) {
|
|
129
|
-
const message =
|
|
130
|
-
cwd: projectRoot,
|
|
131
|
-
encoding: 'utf-8'
|
|
132
|
-
}).trim();
|
|
134
|
+
const message = git(['log', branch, '--format=%B', '-1'], projectRoot, true) || '';
|
|
133
135
|
|
|
134
136
|
return `📸 This commit has an associated checkpoint:\n\nBranch: ${branch}\n\n${message}`;
|
|
135
137
|
}
|
|
@@ -146,13 +148,13 @@ function explainFromCommit(commitSha, projectRoot) {
|
|
|
146
148
|
|
|
147
149
|
export default async function explain(args) {
|
|
148
150
|
try {
|
|
149
|
-
const projectRoot =
|
|
151
|
+
const projectRoot = getProjectRoot();
|
|
150
152
|
const target = args[0];
|
|
151
153
|
|
|
152
154
|
if (!target) {
|
|
153
155
|
console.log('Usage: shit explain <session-id | commit-sha>');
|
|
154
156
|
console.log('\nExamples:');
|
|
155
|
-
console.log(' shit explain
|
|
157
|
+
console.log(' shit explain b5613b31-c732-4546-9be5-f8ae36f2327f # Explain a session');
|
|
156
158
|
console.log(' shit explain abc1234 # Explain a commit');
|
|
157
159
|
process.exit(1);
|
|
158
160
|
}
|
|
@@ -170,23 +172,26 @@ export default async function explain(args) {
|
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
// Try as commit
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return;
|
|
182
|
-
} catch {
|
|
183
|
-
// Not a commit
|
|
175
|
+
const isCommitLike = /^[a-f0-9]{4,40}$/i.test(target);
|
|
176
|
+
if (isCommitLike) {
|
|
177
|
+
const resolvedCommit = git(['rev-parse', '--verify', `${target}^{commit}`], projectRoot, true);
|
|
178
|
+
if (resolvedCommit) {
|
|
179
|
+
console.log('📸 Commit Explanation\n');
|
|
180
|
+
console.log(explainFromCommit(resolvedCommit, projectRoot));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
184
183
|
}
|
|
185
184
|
|
|
185
|
+
// Not a commit
|
|
186
|
+
|
|
186
187
|
// Try partial match
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
const logDir = join(projectRoot, '.shit-logs');
|
|
189
|
+
const sessions = existsSync(logDir)
|
|
190
|
+
? readdirSync(logDir, { withFileTypes: true })
|
|
191
|
+
.filter(entry => entry.isDirectory() && SESSION_ID_REGEX.test(entry.name))
|
|
192
|
+
.map(entry => entry.name)
|
|
193
|
+
.filter(name => name.includes(target))
|
|
194
|
+
: [];
|
|
190
195
|
|
|
191
196
|
if (sessions.length > 0) {
|
|
192
197
|
const matchedSession = sessions[0];
|
package/lib/reset.js
CHANGED
|
@@ -5,20 +5,10 @@
|
|
|
5
5
|
* Similar to 'entire reset' - removes shadow branch and session state
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync,
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { execSync } from 'child_process';
|
|
11
|
-
|
|
12
|
-
function findProjectRoot() {
|
|
13
|
-
let dir = process.cwd();
|
|
14
|
-
while (dir !== '/') {
|
|
15
|
-
if (existsSync(join(dir, '.git'))) {
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
dir = join(dir, '..');
|
|
19
|
-
}
|
|
20
|
-
throw new Error('Not in a git repository');
|
|
21
|
-
}
|
|
11
|
+
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
22
12
|
|
|
23
13
|
function git(cmd, cwd) {
|
|
24
14
|
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
@@ -53,9 +43,11 @@ function findActiveSession(projectRoot) {
|
|
|
53
43
|
return null;
|
|
54
44
|
}
|
|
55
45
|
|
|
56
|
-
const { readdirSync, statSync } = require('fs');
|
|
57
46
|
const sessions = readdirSync(shitLogsDir)
|
|
58
|
-
.filter(name =>
|
|
47
|
+
.filter(name => {
|
|
48
|
+
const fullPath = join(shitLogsDir, name);
|
|
49
|
+
return SESSION_ID_REGEX.test(name) && statSync(fullPath).isDirectory();
|
|
50
|
+
})
|
|
59
51
|
.map(name => ({
|
|
60
52
|
name,
|
|
61
53
|
path: join(shitLogsDir, name),
|
|
@@ -68,7 +60,7 @@ function findActiveSession(projectRoot) {
|
|
|
68
60
|
|
|
69
61
|
export default async function reset(args) {
|
|
70
62
|
try {
|
|
71
|
-
const projectRoot =
|
|
63
|
+
const projectRoot = getProjectRoot();
|
|
72
64
|
const force = args.includes('--force') || args.includes('-f');
|
|
73
65
|
|
|
74
66
|
// Get current commit
|
|
@@ -112,7 +104,7 @@ export default async function reset(args) {
|
|
|
112
104
|
if (state.checkpoints && state.checkpoints.some(cp => cp.linked_commit === commitSha)) {
|
|
113
105
|
// Remove checkpoint references
|
|
114
106
|
state.checkpoints = state.checkpoints.filter(cp => cp.linked_commit !== commitSha);
|
|
115
|
-
|
|
107
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
116
108
|
console.log('✅ Updated session state');
|
|
117
109
|
}
|
|
118
110
|
} catch {
|
package/lib/resume.js
CHANGED
|
@@ -8,17 +8,8 @@
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { execSync } from 'child_process';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
let dir = process.cwd();
|
|
14
|
-
while (dir !== '/') {
|
|
15
|
-
if (existsSync(join(dir, '.git'))) {
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
dir = join(dir, '..');
|
|
19
|
-
}
|
|
20
|
-
throw new Error('Not in a git repository');
|
|
21
|
-
}
|
|
11
|
+
import { randomUUID } from 'crypto';
|
|
12
|
+
import { getProjectRoot } from './config.js';
|
|
22
13
|
|
|
23
14
|
function git(cmd, cwd) {
|
|
24
15
|
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
@@ -26,19 +17,37 @@ function git(cmd, cwd) {
|
|
|
26
17
|
|
|
27
18
|
function findCheckpoint(projectRoot, checkpointId) {
|
|
28
19
|
try {
|
|
29
|
-
const branches = git('branch --list "shit/*"', projectRoot)
|
|
20
|
+
const branches = git('branch --list "shit/checkpoints/v1/*" "shit/*"', projectRoot)
|
|
30
21
|
.split('\n')
|
|
31
22
|
.map(b => b.trim().replace(/^\*?\s*/, ''))
|
|
32
23
|
.filter(Boolean);
|
|
33
24
|
|
|
34
25
|
for (const branch of branches) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
26
|
+
const timestamp = git(`log ${branch} --format=%ci -1`, projectRoot);
|
|
27
|
+
const log = git(`log ${branch} --oneline -1`, projectRoot);
|
|
28
|
+
|
|
29
|
+
const checkpointMatch = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
|
|
30
|
+
if (checkpointMatch) {
|
|
31
|
+
const branchKey = `${checkpointMatch[1]}-${checkpointMatch[2]}`;
|
|
32
|
+
const message = git(`log ${branch} --format=%B -1`, projectRoot);
|
|
33
|
+
const linkedMatch = message.match(/@ ([a-f0-9]+)/);
|
|
34
|
+
const baseCommit = linkedMatch ? linkedMatch[1] : null;
|
|
35
|
+
|
|
36
|
+
if (branchKey.startsWith(checkpointId) || checkpointMatch[2].startsWith(checkpointId) || (baseCommit && baseCommit.startsWith(checkpointId))) {
|
|
37
|
+
return {
|
|
38
|
+
branch,
|
|
39
|
+
baseCommit,
|
|
40
|
+
sessionShort: checkpointMatch[2],
|
|
41
|
+
timestamp,
|
|
42
|
+
message: log.split(' ').slice(1).join(' ')
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
41
46
|
|
|
47
|
+
const shadowMatch = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
|
|
48
|
+
if (shadowMatch) {
|
|
49
|
+
const [, baseCommit, sessionShort] = shadowMatch;
|
|
50
|
+
if (sessionShort.startsWith(checkpointId) || baseCommit.startsWith(checkpointId)) {
|
|
42
51
|
return {
|
|
43
52
|
branch,
|
|
44
53
|
baseCommit,
|
|
@@ -116,11 +125,7 @@ function restoreSessionData(projectRoot, sessionData, newSessionId) {
|
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
function generateSessionId() {
|
|
119
|
-
|
|
120
|
-
const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
121
|
-
const uuid = Math.random().toString(36).substring(2, 15) +
|
|
122
|
-
Math.random().toString(36).substring(2, 15);
|
|
123
|
-
return `${date}-${uuid}`;
|
|
128
|
+
return randomUUID();
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
function updateSessionState(sessionDir, checkpoint) {
|
|
@@ -149,7 +154,7 @@ function updateSessionState(sessionDir, checkpoint) {
|
|
|
149
154
|
|
|
150
155
|
export default async function resume(args) {
|
|
151
156
|
try {
|
|
152
|
-
const projectRoot =
|
|
157
|
+
const projectRoot = getProjectRoot();
|
|
153
158
|
const checkpointId = args.find(arg => !arg.startsWith('-'));
|
|
154
159
|
|
|
155
160
|
if (!checkpointId) {
|
|
@@ -164,19 +169,19 @@ export default async function resume(args) {
|
|
|
164
169
|
const checkpoint = findCheckpoint(projectRoot, checkpointId);
|
|
165
170
|
if (!checkpoint) {
|
|
166
171
|
console.error(`❌ Checkpoint not found: ${checkpointId}`);
|
|
167
|
-
console.error(' Use "shit
|
|
172
|
+
console.error(' Use "shit checkpoints" to list available checkpoints');
|
|
168
173
|
process.exit(1);
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
console.log(`✅ Found checkpoint: ${checkpoint.sessionShort}`);
|
|
172
177
|
console.log(` Branch: ${checkpoint.branch}`);
|
|
173
|
-
console.log(` Base commit: ${checkpoint.baseCommit}`);
|
|
178
|
+
console.log(` Base commit: ${checkpoint.baseCommit || 'unknown'}`);
|
|
174
179
|
console.log(` Created: ${new Date(checkpoint.timestamp).toLocaleString()}`);
|
|
175
180
|
console.log();
|
|
176
181
|
|
|
177
182
|
// Check if we're already at the right commit
|
|
178
183
|
const currentCommit = git('rev-parse HEAD', projectRoot);
|
|
179
|
-
if (!currentCommit.startsWith(checkpoint.baseCommit)) {
|
|
184
|
+
if (checkpoint.baseCommit && !currentCommit.startsWith(checkpoint.baseCommit)) {
|
|
180
185
|
console.log(`🔄 Checking out base commit: ${checkpoint.baseCommit}`);
|
|
181
186
|
git(`checkout ${checkpoint.baseCommit}`, projectRoot);
|
|
182
187
|
}
|
package/lib/rewind.js
CHANGED
|
@@ -5,54 +5,71 @@
|
|
|
5
5
|
* Similar to 'entire rewind' - rollback to known good state
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
10
8
|
import { execSync } from 'child_process';
|
|
11
|
-
|
|
12
|
-
function findProjectRoot() {
|
|
13
|
-
let dir = process.cwd();
|
|
14
|
-
while (dir !== '/') {
|
|
15
|
-
if (existsSync(join(dir, '.git'))) {
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
dir = join(dir, '..');
|
|
19
|
-
}
|
|
20
|
-
throw new Error('Not in a git repository');
|
|
21
|
-
}
|
|
9
|
+
import { getProjectRoot } from './config.js';
|
|
22
10
|
|
|
23
11
|
function git(cmd, cwd) {
|
|
24
12
|
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
25
13
|
}
|
|
26
14
|
|
|
15
|
+
function parseCheckpointRef(projectRoot, branch) {
|
|
16
|
+
const shadowMatch = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
|
|
17
|
+
if (shadowMatch) {
|
|
18
|
+
return {
|
|
19
|
+
type: 'shadow',
|
|
20
|
+
baseCommit: shadowMatch[1],
|
|
21
|
+
sessionShort: shadowMatch[2],
|
|
22
|
+
lookupKey: shadowMatch[2],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const checkpointMatch = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
|
|
27
|
+
if (checkpointMatch) {
|
|
28
|
+
const message = git(`log ${branch} --format=%B -1`, projectRoot);
|
|
29
|
+
const linkedMatch = message.match(/@ ([a-f0-9]+)/);
|
|
30
|
+
const date = checkpointMatch[1];
|
|
31
|
+
const sessionShort = checkpointMatch[2];
|
|
32
|
+
return {
|
|
33
|
+
type: 'checkpoint',
|
|
34
|
+
baseCommit: linkedMatch ? linkedMatch[1] : null,
|
|
35
|
+
sessionShort,
|
|
36
|
+
lookupKey: `${date}-${sessionShort}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
function listCheckpoints(projectRoot) {
|
|
28
44
|
try {
|
|
29
|
-
|
|
30
|
-
const branches = git('branch --list "shit/*"', projectRoot)
|
|
45
|
+
const branches = git('branch --list "shit/checkpoints/v1/*" "shit/*"', projectRoot)
|
|
31
46
|
.split('\n')
|
|
32
47
|
.map(b => b.trim().replace(/^\*?\s*/, ''))
|
|
33
48
|
.filter(Boolean);
|
|
49
|
+
const uniqueBranches = [...new Set(branches)];
|
|
34
50
|
|
|
35
51
|
const checkpoints = [];
|
|
36
52
|
|
|
37
|
-
for (const branch of
|
|
53
|
+
for (const branch of uniqueBranches) {
|
|
38
54
|
try {
|
|
55
|
+
const parsed = parseCheckpointRef(projectRoot, branch);
|
|
56
|
+
if (!parsed) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
const log = git(`log ${branch} --oneline -1`, projectRoot);
|
|
40
61
|
const [commit, ...messageParts] = log.split(' ');
|
|
41
62
|
const message = messageParts.join(' ');
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
message,
|
|
53
|
-
timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
|
|
54
|
-
});
|
|
55
|
-
}
|
|
63
|
+
checkpoints.push({
|
|
64
|
+
branch,
|
|
65
|
+
commit,
|
|
66
|
+
baseCommit: parsed.baseCommit,
|
|
67
|
+
sessionShort: parsed.sessionShort,
|
|
68
|
+
lookupKey: parsed.lookupKey,
|
|
69
|
+
type: parsed.type,
|
|
70
|
+
message,
|
|
71
|
+
timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
|
|
72
|
+
});
|
|
56
73
|
} catch {
|
|
57
74
|
// Skip invalid branches
|
|
58
75
|
}
|
|
@@ -84,6 +101,10 @@ function hasUncommittedChanges(projectRoot) {
|
|
|
84
101
|
function rewindToCheckpoint(projectRoot, checkpoint, force = false) {
|
|
85
102
|
const currentCommit = getCurrentCommit(projectRoot);
|
|
86
103
|
|
|
104
|
+
if (!checkpoint.baseCommit) {
|
|
105
|
+
throw new Error(`Checkpoint ${checkpoint.lookupKey || checkpoint.sessionShort} is missing a linked base commit`);
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
if (!force && hasUncommittedChanges(projectRoot)) {
|
|
88
109
|
throw new Error('Working directory has uncommitted changes. Use --force to override or commit changes first.');
|
|
89
110
|
}
|
|
@@ -100,7 +121,7 @@ function rewindToCheckpoint(projectRoot, checkpoint, force = false) {
|
|
|
100
121
|
|
|
101
122
|
export default async function rewind(args) {
|
|
102
123
|
try {
|
|
103
|
-
const projectRoot =
|
|
124
|
+
const projectRoot = getProjectRoot();
|
|
104
125
|
const force = args.includes('--force') || args.includes('-f');
|
|
105
126
|
const interactive = args.includes('--interactive') || args.includes('-i');
|
|
106
127
|
|
|
@@ -112,7 +133,7 @@ export default async function rewind(args) {
|
|
|
112
133
|
|
|
113
134
|
if (checkpoints.length === 0) {
|
|
114
135
|
console.log('❌ No checkpoints found');
|
|
115
|
-
console.log(' Checkpoints are created
|
|
136
|
+
console.log(' Checkpoints are created when you run "shit commit".');
|
|
116
137
|
process.exit(1);
|
|
117
138
|
}
|
|
118
139
|
|
|
@@ -120,7 +141,8 @@ export default async function rewind(args) {
|
|
|
120
141
|
// Find specific checkpoint
|
|
121
142
|
targetCheckpoint = checkpoints.find(cp =>
|
|
122
143
|
cp.sessionShort.startsWith(checkpointArg) ||
|
|
123
|
-
cp.
|
|
144
|
+
cp.lookupKey.startsWith(checkpointArg) ||
|
|
145
|
+
(cp.baseCommit && cp.baseCommit.startsWith(checkpointArg))
|
|
124
146
|
);
|
|
125
147
|
|
|
126
148
|
if (!targetCheckpoint) {
|
|
@@ -132,22 +154,25 @@ export default async function rewind(args) {
|
|
|
132
154
|
console.log('📋 Available checkpoints:\n');
|
|
133
155
|
checkpoints.forEach((cp, i) => {
|
|
134
156
|
const date = new Date(cp.timestamp).toLocaleString();
|
|
135
|
-
|
|
157
|
+
const base = cp.baseCommit ? cp.baseCommit.slice(0, 7) : 'unknown';
|
|
158
|
+
const key = cp.lookupKey || cp.sessionShort;
|
|
159
|
+
console.log(`${i + 1}. ${key} (${base}) - ${date}`);
|
|
136
160
|
console.log(` ${cp.message}`);
|
|
137
161
|
console.log();
|
|
138
162
|
});
|
|
139
163
|
|
|
140
164
|
// For now, just use the most recent
|
|
141
165
|
targetCheckpoint = checkpoints[0];
|
|
142
|
-
console.log(`Using most recent checkpoint: ${targetCheckpoint.sessionShort}`);
|
|
166
|
+
console.log(`Using most recent checkpoint: ${targetCheckpoint.lookupKey || targetCheckpoint.sessionShort}`);
|
|
143
167
|
} else {
|
|
144
168
|
// Use most recent checkpoint
|
|
145
169
|
targetCheckpoint = checkpoints[0];
|
|
146
170
|
}
|
|
147
171
|
|
|
148
|
-
|
|
172
|
+
const selectedKey = targetCheckpoint.lookupKey || targetCheckpoint.sessionShort;
|
|
173
|
+
console.log(`🔄 Rewinding to checkpoint: ${selectedKey}`);
|
|
149
174
|
console.log(` Branch: ${targetCheckpoint.branch}`);
|
|
150
|
-
console.log(` Base commit: ${targetCheckpoint.baseCommit}`);
|
|
175
|
+
console.log(` Base commit: ${targetCheckpoint.baseCommit || 'unknown'}`);
|
|
151
176
|
console.log(` Created: ${new Date(targetCheckpoint.timestamp).toLocaleString()}`);
|
|
152
177
|
console.log();
|
|
153
178
|
|
|
@@ -164,7 +189,7 @@ export default async function rewind(args) {
|
|
|
164
189
|
console.log(` git reset --hard ${previousCommit}`);
|
|
165
190
|
console.log();
|
|
166
191
|
console.log('💡 To resume from this checkpoint:');
|
|
167
|
-
console.log(` shit resume ${
|
|
192
|
+
console.log(` shit resume ${selectedKey}`);
|
|
168
193
|
|
|
169
194
|
} catch (error) {
|
|
170
195
|
console.error('❌ Failed to rewind:', error.message);
|
package/lib/session.js
CHANGED
|
@@ -105,6 +105,12 @@ export function processEvent(state, event, hookType, projectRoot) {
|
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Mark session as ended for end/stop hooks.
|
|
109
|
+
if (hookType === 'session-end' || hookType === 'SessionEnd' || hookType === 'stop' || hookType === 'session_end' || hookType === 'end') {
|
|
110
|
+
state.end_time = now;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
108
114
|
// Tool events
|
|
109
115
|
if (hookType === 'post-tool-use' && toolName) {
|
|
110
116
|
state.tool_counts[toolName] = (state.tool_counts[toolName] || 0) + 1;
|