@claudemini/shit-cli 1.0.3 → 1.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/.claude/settings.local.json +2 -1
- package/bin/shit.js +20 -13
- package/lib/disable.js +116 -0
- package/lib/doctor.js +314 -0
- package/lib/enable.js +109 -0
- package/lib/resume.js +219 -0
- package/lib/rewind.js +173 -0
- package/lib/status.js +173 -0
- package/package.json +1 -1
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"Bash(git add:*)",
|
|
14
14
|
"Bash(git commit -m \"$\\(cat <<''EOF''\n更新包名和版本号\n\n- 包名改为 @cluademini/shit-cli\n- 版本号升至 1.0.3\nEOF\n\\)\")",
|
|
15
15
|
"Bash(git commit:*)",
|
|
16
|
-
"Bash(npm config set:*)"
|
|
16
|
+
"Bash(npm config set:*)",
|
|
17
|
+
"WebFetch(domain:github.com)"
|
|
17
18
|
]
|
|
18
19
|
}
|
|
19
20
|
}
|
package/bin/shit.js
CHANGED
|
@@ -4,14 +4,20 @@ const args = process.argv.slice(2);
|
|
|
4
4
|
const command = args[0];
|
|
5
5
|
|
|
6
6
|
const commands = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
enable: 'Enable shit-cli in repository',
|
|
8
|
+
disable: 'Remove shit-cli hooks',
|
|
9
|
+
status: 'Show current session status',
|
|
10
|
+
init: 'Initialize hooks in .claude/settings.json',
|
|
11
|
+
log: 'Log a hook event (called by hooks)',
|
|
12
|
+
list: 'List all sessions',
|
|
13
|
+
view: 'View session details',
|
|
14
|
+
query: 'Query session memory (cross-session)',
|
|
15
|
+
rewind: 'Rollback to previous checkpoint',
|
|
16
|
+
resume: 'Resume session from checkpoint',
|
|
17
|
+
doctor: 'Fix or clean stuck sessions',
|
|
18
|
+
shadow: 'List shadow branches',
|
|
19
|
+
clean: 'Clean old sessions',
|
|
20
|
+
help: 'Show help',
|
|
15
21
|
};
|
|
16
22
|
|
|
17
23
|
function showHelp() {
|
|
@@ -22,16 +28,17 @@ function showHelp() {
|
|
|
22
28
|
console.log(` ${cmd.padEnd(10)} ${desc}`);
|
|
23
29
|
});
|
|
24
30
|
console.log('\nExamples:');
|
|
25
|
-
console.log(' shit
|
|
31
|
+
console.log(' shit enable # Enable shit-cli in repo');
|
|
32
|
+
console.log(' shit status # Show current session');
|
|
26
33
|
console.log(' shit list # List sessions');
|
|
27
34
|
console.log(' shit view <session-id> # View session');
|
|
28
|
-
console.log(' shit
|
|
35
|
+
console.log(' shit rewind <checkpoint> # Rollback to checkpoint');
|
|
36
|
+
console.log(' shit resume <checkpoint> # Resume from checkpoint');
|
|
37
|
+
console.log(' shit doctor --fix # Fix stuck sessions');
|
|
29
38
|
console.log(' shit query --recent=5 # Recent 5 sessions');
|
|
30
39
|
console.log(' shit query --file=src/app.ts # Sessions that touched file');
|
|
31
|
-
console.log(' shit query --type=bugfix # Filter by session type');
|
|
32
|
-
console.log(' shit query --risk=high --json # High-risk sessions as JSON');
|
|
33
40
|
console.log(' shit shadow # List shadow branches');
|
|
34
|
-
console.log(' shit
|
|
41
|
+
console.log(' shit disable --clean # Remove shit-cli and data');
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
if (!command || command === 'help' || command === '--help') {
|
package/lib/disable.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Disable shit-cli in repository
|
|
5
|
+
* Similar to 'entire disable' - removes hooks and optionally cleans data
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
function findProjectRoot() {
|
|
12
|
+
let dir = process.cwd();
|
|
13
|
+
while (dir !== '/') {
|
|
14
|
+
if (existsSync(join(dir, '.git'))) {
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
dir = join(dir, '..');
|
|
18
|
+
}
|
|
19
|
+
throw new Error('Not in a git repository');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function removeClaudeHooks(projectRoot) {
|
|
23
|
+
const settingsFile = join(projectRoot, '.claude', 'settings.json');
|
|
24
|
+
|
|
25
|
+
if (!existsSync(settingsFile)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
|
|
31
|
+
|
|
32
|
+
if (settings.hooks) {
|
|
33
|
+
// Remove shit-cli hooks
|
|
34
|
+
delete settings.hooks.session_start;
|
|
35
|
+
delete settings.hooks.session_end;
|
|
36
|
+
delete settings.hooks.tool_use;
|
|
37
|
+
delete settings.hooks.edit_applied;
|
|
38
|
+
|
|
39
|
+
// If hooks object is empty, remove it
|
|
40
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
41
|
+
delete settings.hooks;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeFromGitignore(projectRoot) {
|
|
53
|
+
const gitignoreFile = join(projectRoot, '.gitignore');
|
|
54
|
+
|
|
55
|
+
if (!existsSync(gitignoreFile)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
let gitignore = readFileSync(gitignoreFile, 'utf-8');
|
|
61
|
+
const lines = gitignore.split('\n');
|
|
62
|
+
const filtered = lines.filter(line => !line.trim().startsWith('.shit-logs'));
|
|
63
|
+
|
|
64
|
+
if (filtered.length !== lines.length) {
|
|
65
|
+
writeFileSync(gitignoreFile, filtered.join('\n'));
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Ignore errors
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default async function disable(args) {
|
|
76
|
+
try {
|
|
77
|
+
const projectRoot = findProjectRoot();
|
|
78
|
+
const cleanData = args.includes('--clean') || args.includes('--purge');
|
|
79
|
+
|
|
80
|
+
console.log('🔧 Disabling shit-cli in repository...');
|
|
81
|
+
|
|
82
|
+
// Remove Claude Code hooks
|
|
83
|
+
const hooksRemoved = removeClaudeHooks(projectRoot);
|
|
84
|
+
if (hooksRemoved) {
|
|
85
|
+
console.log('✅ Removed Claude hooks from .claude/settings.json');
|
|
86
|
+
} else {
|
|
87
|
+
console.log('ℹ️ No Claude hooks found to remove');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Remove from .gitignore
|
|
91
|
+
const gitignoreUpdated = removeFromGitignore(projectRoot);
|
|
92
|
+
if (gitignoreUpdated) {
|
|
93
|
+
console.log('✅ Removed .shit-logs from .gitignore');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Optionally clean data
|
|
97
|
+
if (cleanData) {
|
|
98
|
+
const shitLogsDir = join(projectRoot, '.shit-logs');
|
|
99
|
+
if (existsSync(shitLogsDir)) {
|
|
100
|
+
rmSync(shitLogsDir, { recursive: true, force: true });
|
|
101
|
+
console.log('✅ Removed .shit-logs directory');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log('\n🎉 shit-cli disabled successfully!');
|
|
106
|
+
|
|
107
|
+
if (!cleanData) {
|
|
108
|
+
console.log('\nNote: .shit-logs directory preserved.');
|
|
109
|
+
console.log('Use "shit disable --clean" to remove all data.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error('❌ Failed to disable shit-cli:', error.message);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Doctor command - fix or clean stuck sessions
|
|
5
|
+
* Similar to 'entire doctor' - repairs corrupted state and cleans up
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
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
|
+
}
|
|
22
|
+
|
|
23
|
+
function git(cmd, cwd) {
|
|
24
|
+
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkShitLogsStructure(projectRoot) {
|
|
28
|
+
const issues = [];
|
|
29
|
+
const shitLogsDir = join(projectRoot, '.shit-logs');
|
|
30
|
+
|
|
31
|
+
if (!existsSync(shitLogsDir)) {
|
|
32
|
+
issues.push({
|
|
33
|
+
type: 'missing_directory',
|
|
34
|
+
message: '.shit-logs directory not found',
|
|
35
|
+
fix: () => {
|
|
36
|
+
const { mkdirSync } = require('fs');
|
|
37
|
+
mkdirSync(shitLogsDir, { recursive: true });
|
|
38
|
+
return 'Created .shit-logs directory';
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check index.json
|
|
45
|
+
const indexFile = join(shitLogsDir, 'index.json');
|
|
46
|
+
if (!existsSync(indexFile)) {
|
|
47
|
+
issues.push({
|
|
48
|
+
type: 'missing_index',
|
|
49
|
+
message: 'index.json not found',
|
|
50
|
+
fix: () => {
|
|
51
|
+
const initialIndex = {
|
|
52
|
+
version: 2,
|
|
53
|
+
sessions: [],
|
|
54
|
+
files: {},
|
|
55
|
+
created: new Date().toISOString()
|
|
56
|
+
};
|
|
57
|
+
writeFileSync(indexFile, JSON.stringify(initialIndex, null, 2));
|
|
58
|
+
return 'Created index.json';
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
} else {
|
|
62
|
+
try {
|
|
63
|
+
JSON.parse(readFileSync(indexFile, 'utf-8'));
|
|
64
|
+
} catch {
|
|
65
|
+
issues.push({
|
|
66
|
+
type: 'corrupted_index',
|
|
67
|
+
message: 'index.json is corrupted',
|
|
68
|
+
fix: () => {
|
|
69
|
+
const backup = `${indexFile}.backup.${Date.now()}`;
|
|
70
|
+
const { copyFileSync } = require('fs');
|
|
71
|
+
copyFileSync(indexFile, backup);
|
|
72
|
+
|
|
73
|
+
const initialIndex = {
|
|
74
|
+
version: 2,
|
|
75
|
+
sessions: [],
|
|
76
|
+
files: {},
|
|
77
|
+
created: new Date().toISOString(),
|
|
78
|
+
recovered: true
|
|
79
|
+
};
|
|
80
|
+
writeFileSync(indexFile, JSON.stringify(initialIndex, null, 2));
|
|
81
|
+
return `Recreated index.json (backup saved as ${backup})`;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return issues;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function checkSessionDirectories(projectRoot) {
|
|
91
|
+
const issues = [];
|
|
92
|
+
const shitLogsDir = join(projectRoot, '.shit-logs');
|
|
93
|
+
|
|
94
|
+
if (!existsSync(shitLogsDir)) {
|
|
95
|
+
return issues;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const entries = readdirSync(shitLogsDir);
|
|
99
|
+
const sessionDirs = entries.filter(name => {
|
|
100
|
+
const fullPath = join(shitLogsDir, name);
|
|
101
|
+
return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
for (const sessionDir of sessionDirs) {
|
|
105
|
+
const sessionPath = join(shitLogsDir, sessionDir);
|
|
106
|
+
const stateFile = join(sessionPath, 'state.json');
|
|
107
|
+
|
|
108
|
+
if (!existsSync(stateFile)) {
|
|
109
|
+
issues.push({
|
|
110
|
+
type: 'missing_state',
|
|
111
|
+
message: `Session ${sessionDir} missing state.json`,
|
|
112
|
+
sessionDir,
|
|
113
|
+
fix: () => {
|
|
114
|
+
const defaultState = {
|
|
115
|
+
session_id: sessionDir,
|
|
116
|
+
start_time: new Date().toISOString(),
|
|
117
|
+
event_count: 0,
|
|
118
|
+
files: {},
|
|
119
|
+
recovered: true
|
|
120
|
+
};
|
|
121
|
+
writeFileSync(stateFile, JSON.stringify(defaultState, null, 2));
|
|
122
|
+
return `Created state.json for ${sessionDir}`;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
128
|
+
} catch {
|
|
129
|
+
issues.push({
|
|
130
|
+
type: 'corrupted_state',
|
|
131
|
+
message: `Session ${sessionDir} has corrupted state.json`,
|
|
132
|
+
sessionDir,
|
|
133
|
+
fix: () => {
|
|
134
|
+
const backup = `${stateFile}.backup.${Date.now()}`;
|
|
135
|
+
const { copyFileSync } = require('fs');
|
|
136
|
+
copyFileSync(stateFile, backup);
|
|
137
|
+
|
|
138
|
+
const defaultState = {
|
|
139
|
+
session_id: sessionDir,
|
|
140
|
+
start_time: new Date().toISOString(),
|
|
141
|
+
event_count: 0,
|
|
142
|
+
files: {},
|
|
143
|
+
recovered: true
|
|
144
|
+
};
|
|
145
|
+
writeFileSync(stateFile, JSON.stringify(defaultState, null, 2));
|
|
146
|
+
return `Recreated state.json for ${sessionDir} (backup: ${backup})`;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return issues;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function checkOrphanedShadowBranches(projectRoot) {
|
|
157
|
+
const issues = [];
|
|
158
|
+
const shitLogsDir = join(projectRoot, '.shit-logs');
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const branches = git('branch --list "shit/*"', projectRoot)
|
|
162
|
+
.split('\n')
|
|
163
|
+
.map(b => b.trim().replace(/^\*?\s*/, ''))
|
|
164
|
+
.filter(Boolean);
|
|
165
|
+
|
|
166
|
+
const sessionDirs = existsSync(shitLogsDir) ?
|
|
167
|
+
readdirSync(shitLogsDir).filter(name => {
|
|
168
|
+
const fullPath = join(shitLogsDir, name);
|
|
169
|
+
return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
|
|
170
|
+
}) : [];
|
|
171
|
+
|
|
172
|
+
for (const branch of branches) {
|
|
173
|
+
const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
|
|
174
|
+
if (match) {
|
|
175
|
+
const [, baseCommit, sessionShort] = match;
|
|
176
|
+
|
|
177
|
+
// Check if corresponding session exists
|
|
178
|
+
const hasSession = sessionDirs.some(dir => dir.includes(sessionShort));
|
|
179
|
+
|
|
180
|
+
if (!hasSession) {
|
|
181
|
+
issues.push({
|
|
182
|
+
type: 'orphaned_shadow',
|
|
183
|
+
message: `Orphaned shadow branch: ${branch}`,
|
|
184
|
+
branch,
|
|
185
|
+
fix: () => {
|
|
186
|
+
git(`branch -D ${branch}`, projectRoot);
|
|
187
|
+
return `Deleted orphaned shadow branch: ${branch}`;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Git operations failed, skip shadow branch check
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return issues;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function checkStuckSessions(projectRoot) {
|
|
201
|
+
const issues = [];
|
|
202
|
+
const shitLogsDir = join(projectRoot, '.shit-logs');
|
|
203
|
+
|
|
204
|
+
if (!existsSync(shitLogsDir)) {
|
|
205
|
+
return issues;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const entries = readdirSync(shitLogsDir);
|
|
209
|
+
const sessionDirs = entries.filter(name => {
|
|
210
|
+
const fullPath = join(shitLogsDir, name);
|
|
211
|
+
return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const now = new Date();
|
|
215
|
+
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
216
|
+
|
|
217
|
+
for (const sessionDir of sessionDirs) {
|
|
218
|
+
const sessionPath = join(shitLogsDir, sessionDir);
|
|
219
|
+
const stateFile = join(sessionPath, 'state.json');
|
|
220
|
+
|
|
221
|
+
if (existsSync(stateFile)) {
|
|
222
|
+
try {
|
|
223
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
224
|
+
const startTime = new Date(state.start_time);
|
|
225
|
+
|
|
226
|
+
// Check for sessions older than 24 hours without end_time
|
|
227
|
+
if (!state.end_time && startTime < oneDayAgo) {
|
|
228
|
+
issues.push({
|
|
229
|
+
type: 'stuck_session',
|
|
230
|
+
message: `Stuck session: ${sessionDir} (started ${startTime.toLocaleString()})`,
|
|
231
|
+
sessionDir,
|
|
232
|
+
fix: () => {
|
|
233
|
+
state.end_time = new Date().toISOString();
|
|
234
|
+
state.stuck_session_recovered = true;
|
|
235
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
236
|
+
return `Marked stuck session as ended: ${sessionDir}`;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// Already handled by checkSessionDirectories
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return issues;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export default async function doctor(args) {
|
|
250
|
+
try {
|
|
251
|
+
const projectRoot = findProjectRoot();
|
|
252
|
+
const autoFix = args.includes('--fix') || args.includes('--auto-fix');
|
|
253
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
254
|
+
|
|
255
|
+
console.log('🩺 Running shit-cli diagnostics...\n');
|
|
256
|
+
|
|
257
|
+
// Collect all issues
|
|
258
|
+
const allIssues = [
|
|
259
|
+
...checkShitLogsStructure(projectRoot),
|
|
260
|
+
...checkSessionDirectories(projectRoot),
|
|
261
|
+
...checkOrphanedShadowBranches(projectRoot),
|
|
262
|
+
...checkStuckSessions(projectRoot)
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
if (allIssues.length === 0) {
|
|
266
|
+
console.log('✅ No issues found! shit-cli is healthy.');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log(`🔍 Found ${allIssues.length} issue(s):\n`);
|
|
271
|
+
|
|
272
|
+
let fixedCount = 0;
|
|
273
|
+
for (const [index, issue] of allIssues.entries()) {
|
|
274
|
+
const prefix = `${index + 1}.`;
|
|
275
|
+
console.log(`${prefix} ${issue.message}`);
|
|
276
|
+
|
|
277
|
+
if (verbose) {
|
|
278
|
+
console.log(` Type: ${issue.type}`);
|
|
279
|
+
if (issue.sessionDir) {
|
|
280
|
+
console.log(` Session: ${issue.sessionDir}`);
|
|
281
|
+
}
|
|
282
|
+
if (issue.branch) {
|
|
283
|
+
console.log(` Branch: ${issue.branch}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (autoFix && issue.fix) {
|
|
288
|
+
try {
|
|
289
|
+
const result = issue.fix();
|
|
290
|
+
console.log(` ✅ Fixed: ${result}`);
|
|
291
|
+
fixedCount++;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.log(` ❌ Fix failed: ${error.message}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
console.log();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (autoFix) {
|
|
301
|
+
console.log(`🎉 Fixed ${fixedCount}/${allIssues.length} issues.`);
|
|
302
|
+
if (fixedCount < allIssues.length) {
|
|
303
|
+
console.log(' Some issues could not be automatically fixed.');
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
console.log('💡 To automatically fix these issues, run:');
|
|
307
|
+
console.log(' shit doctor --fix');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error('❌ Doctor failed:', error.message);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
}
|
package/lib/enable.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enable shit-cli in repository
|
|
5
|
+
* Similar to 'entire enable' - sets up hooks and configuration
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
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
|
+
}
|
|
22
|
+
|
|
23
|
+
function setupClaudeHooks(projectRoot) {
|
|
24
|
+
const claudeDir = join(projectRoot, '.claude');
|
|
25
|
+
const settingsFile = join(claudeDir, 'settings.json');
|
|
26
|
+
|
|
27
|
+
// Create .claude directory if it doesn't exist
|
|
28
|
+
if (!existsSync(claudeDir)) {
|
|
29
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let settings = {};
|
|
33
|
+
if (existsSync(settingsFile)) {
|
|
34
|
+
try {
|
|
35
|
+
settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
|
|
36
|
+
} catch {
|
|
37
|
+
// Invalid JSON, start fresh
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Add shit-cli hooks
|
|
42
|
+
if (!settings.hooks) settings.hooks = {};
|
|
43
|
+
|
|
44
|
+
settings.hooks.session_start = 'shit log session_start';
|
|
45
|
+
settings.hooks.session_end = 'shit log session_end';
|
|
46
|
+
settings.hooks.tool_use = 'shit log tool_use';
|
|
47
|
+
settings.hooks.edit_applied = 'shit log edit_applied';
|
|
48
|
+
|
|
49
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
|
|
50
|
+
return settingsFile;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function initializeShitLogs(projectRoot) {
|
|
54
|
+
const shitDir = join(projectRoot, '.shit-logs');
|
|
55
|
+
if (!existsSync(shitDir)) {
|
|
56
|
+
mkdirSync(shitDir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Create index.json if it doesn't exist
|
|
60
|
+
const indexFile = join(shitDir, 'index.json');
|
|
61
|
+
if (!existsSync(indexFile)) {
|
|
62
|
+
const initialIndex = {
|
|
63
|
+
version: 2,
|
|
64
|
+
sessions: [],
|
|
65
|
+
files: {},
|
|
66
|
+
created: new Date().toISOString()
|
|
67
|
+
};
|
|
68
|
+
writeFileSync(indexFile, JSON.stringify(initialIndex, null, 2));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default async function enable(args) {
|
|
73
|
+
try {
|
|
74
|
+
const projectRoot = findProjectRoot();
|
|
75
|
+
|
|
76
|
+
console.log('🔧 Enabling shit-cli in repository...');
|
|
77
|
+
|
|
78
|
+
// Setup Claude Code hooks
|
|
79
|
+
const settingsFile = setupClaudeHooks(projectRoot);
|
|
80
|
+
console.log(`✅ Updated Claude hooks in ${settingsFile}`);
|
|
81
|
+
|
|
82
|
+
// Initialize .shit-logs directory
|
|
83
|
+
initializeShitLogs(projectRoot);
|
|
84
|
+
console.log('✅ Initialized .shit-logs directory');
|
|
85
|
+
|
|
86
|
+
// Add .shit-logs to .gitignore if not already there
|
|
87
|
+
const gitignoreFile = join(projectRoot, '.gitignore');
|
|
88
|
+
let gitignore = '';
|
|
89
|
+
if (existsSync(gitignoreFile)) {
|
|
90
|
+
gitignore = readFileSync(gitignoreFile, 'utf-8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!gitignore.includes('.shit-logs')) {
|
|
94
|
+
const newLine = gitignore.endsWith('\n') || gitignore === '' ? '' : '\n';
|
|
95
|
+
writeFileSync(gitignoreFile, gitignore + newLine + '.shit-logs/\n');
|
|
96
|
+
console.log('✅ Added .shit-logs to .gitignore');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log('\n🎉 shit-cli enabled successfully!');
|
|
100
|
+
console.log('\nNext steps:');
|
|
101
|
+
console.log(' 1. Start a Claude Code session');
|
|
102
|
+
console.log(' 2. Use "shit status" to check session tracking');
|
|
103
|
+
console.log(' 3. Use "shit list" to view logged sessions');
|
|
104
|
+
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('❌ Failed to enable shit-cli:', error.message);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/lib/resume.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resume session from checkpoint
|
|
5
|
+
* Similar to 'entire resume' - restore branch and session metadata
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
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
|
+
}
|
|
22
|
+
|
|
23
|
+
function git(cmd, cwd) {
|
|
24
|
+
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function findCheckpoint(projectRoot, checkpointId) {
|
|
28
|
+
try {
|
|
29
|
+
const branches = git('branch --list "shit/*"', projectRoot)
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map(b => b.trim().replace(/^\*?\s*/, ''))
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
|
|
34
|
+
for (const branch of branches) {
|
|
35
|
+
const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
|
|
36
|
+
if (match) {
|
|
37
|
+
const [, baseCommit, sessionShort] = match;
|
|
38
|
+
if (sessionShort.startsWith(checkpointId) || baseCommit.startsWith(checkpointId)) {
|
|
39
|
+
const log = git(`log ${branch} --oneline -1`, projectRoot);
|
|
40
|
+
const timestamp = git(`log ${branch} --format=%ci -1`, projectRoot);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
branch,
|
|
44
|
+
baseCommit,
|
|
45
|
+
sessionShort,
|
|
46
|
+
timestamp,
|
|
47
|
+
message: log.split(' ').slice(1).join(' ')
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractSessionFromShadow(projectRoot, checkpoint) {
|
|
59
|
+
try {
|
|
60
|
+
// Get files from shadow branch
|
|
61
|
+
const files = git(`ls-tree -r --name-only ${checkpoint.branch}`, projectRoot)
|
|
62
|
+
.split('\n')
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
|
|
65
|
+
const sessionData = {};
|
|
66
|
+
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
if (file.startsWith('.shit-logs/')) {
|
|
69
|
+
try {
|
|
70
|
+
const content = git(`show ${checkpoint.branch}:${file}`, projectRoot);
|
|
71
|
+
sessionData[file] = content;
|
|
72
|
+
} catch {
|
|
73
|
+
// Skip files that can't be read
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return sessionData;
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function restoreSessionData(projectRoot, sessionData, newSessionId) {
|
|
85
|
+
const shitLogsDir = join(projectRoot, '.shit-logs');
|
|
86
|
+
if (!existsSync(shitLogsDir)) {
|
|
87
|
+
mkdirSync(shitLogsDir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sessionDir = join(shitLogsDir, newSessionId);
|
|
91
|
+
if (!existsSync(sessionDir)) {
|
|
92
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let restoredFiles = 0;
|
|
96
|
+
|
|
97
|
+
for (const [filePath, content] of Object.entries(sessionData)) {
|
|
98
|
+
// Extract relative path within session
|
|
99
|
+
const match = filePath.match(/^\.shit-logs\/[^/]+\/(.+)$/);
|
|
100
|
+
if (match) {
|
|
101
|
+
const relativePath = match[1];
|
|
102
|
+
const targetPath = join(sessionDir, relativePath);
|
|
103
|
+
|
|
104
|
+
// Create directory if needed
|
|
105
|
+
const targetDir = join(targetPath, '..');
|
|
106
|
+
if (!existsSync(targetDir)) {
|
|
107
|
+
mkdirSync(targetDir, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
writeFileSync(targetPath, content);
|
|
111
|
+
restoredFiles++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { sessionDir, restoredFiles };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function generateSessionId() {
|
|
119
|
+
const now = new Date();
|
|
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}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function updateSessionState(sessionDir, checkpoint) {
|
|
127
|
+
const stateFile = join(sessionDir, 'state.json');
|
|
128
|
+
|
|
129
|
+
let state = {};
|
|
130
|
+
if (existsSync(stateFile)) {
|
|
131
|
+
try {
|
|
132
|
+
state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
133
|
+
} catch {
|
|
134
|
+
// Invalid JSON, start fresh
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Update state for resumed session
|
|
139
|
+
state.resumed_from = checkpoint.sessionShort;
|
|
140
|
+
state.resumed_at = new Date().toISOString();
|
|
141
|
+
state.original_checkpoint = checkpoint.branch;
|
|
142
|
+
|
|
143
|
+
// Reset some fields for new session
|
|
144
|
+
delete state.end_time;
|
|
145
|
+
delete state.shadow_branch;
|
|
146
|
+
|
|
147
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default async function resume(args) {
|
|
151
|
+
try {
|
|
152
|
+
const projectRoot = findProjectRoot();
|
|
153
|
+
const checkpointId = args.find(arg => !arg.startsWith('-'));
|
|
154
|
+
|
|
155
|
+
if (!checkpointId) {
|
|
156
|
+
console.error('❌ Please specify a checkpoint ID');
|
|
157
|
+
console.error(' Usage: shit resume <checkpoint-id>');
|
|
158
|
+
console.error(' Use "shit list" to see available checkpoints');
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`🔍 Looking for checkpoint: ${checkpointId}`);
|
|
163
|
+
|
|
164
|
+
const checkpoint = findCheckpoint(projectRoot, checkpointId);
|
|
165
|
+
if (!checkpoint) {
|
|
166
|
+
console.error(`❌ Checkpoint not found: ${checkpointId}`);
|
|
167
|
+
console.error(' Use "shit shadow" to list available checkpoints');
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(`✅ Found checkpoint: ${checkpoint.sessionShort}`);
|
|
172
|
+
console.log(` Branch: ${checkpoint.branch}`);
|
|
173
|
+
console.log(` Base commit: ${checkpoint.baseCommit}`);
|
|
174
|
+
console.log(` Created: ${new Date(checkpoint.timestamp).toLocaleString()}`);
|
|
175
|
+
console.log();
|
|
176
|
+
|
|
177
|
+
// Check if we're already at the right commit
|
|
178
|
+
const currentCommit = git('rev-parse HEAD', projectRoot);
|
|
179
|
+
if (!currentCommit.startsWith(checkpoint.baseCommit)) {
|
|
180
|
+
console.log(`🔄 Checking out base commit: ${checkpoint.baseCommit}`);
|
|
181
|
+
git(`checkout ${checkpoint.baseCommit}`, projectRoot);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Extract session data from shadow branch
|
|
185
|
+
console.log('📦 Extracting session data from shadow branch...');
|
|
186
|
+
const sessionData = extractSessionFromShadow(projectRoot, checkpoint);
|
|
187
|
+
|
|
188
|
+
if (Object.keys(sessionData).length === 0) {
|
|
189
|
+
console.error('❌ No session data found in shadow branch');
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create new session ID for resumed session
|
|
194
|
+
const newSessionId = generateSessionId();
|
|
195
|
+
console.log(`🆕 Creating new session: ${newSessionId}`);
|
|
196
|
+
|
|
197
|
+
// Restore session data
|
|
198
|
+
const { sessionDir, restoredFiles } = restoreSessionData(projectRoot, sessionData, newSessionId);
|
|
199
|
+
console.log(`✅ Restored ${restoredFiles} files to ${sessionDir}`);
|
|
200
|
+
|
|
201
|
+
// Update session state
|
|
202
|
+
updateSessionState(sessionDir, checkpoint);
|
|
203
|
+
console.log('✅ Updated session state');
|
|
204
|
+
|
|
205
|
+
console.log();
|
|
206
|
+
console.log('🎉 Session resumed successfully!');
|
|
207
|
+
console.log(` New session ID: ${newSessionId}`);
|
|
208
|
+
console.log(` Resumed from: ${checkpoint.sessionShort}`);
|
|
209
|
+
console.log();
|
|
210
|
+
console.log('💡 Next steps:');
|
|
211
|
+
console.log(' 1. Start Claude Code to continue the session');
|
|
212
|
+
console.log(' 2. Use "shit status" to verify session tracking');
|
|
213
|
+
console.log(' 3. Use "shit view" to see session history');
|
|
214
|
+
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error('❌ Failed to resume session:', error.message);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
}
|
package/lib/rewind.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rewind to previous checkpoint
|
|
5
|
+
* Similar to 'entire rewind' - rollback to known good state
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
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
|
+
}
|
|
22
|
+
|
|
23
|
+
function git(cmd, cwd) {
|
|
24
|
+
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function listCheckpoints(projectRoot) {
|
|
28
|
+
try {
|
|
29
|
+
// Get shadow branches
|
|
30
|
+
const branches = git('branch --list "shit/*"', projectRoot)
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map(b => b.trim().replace(/^\*?\s*/, ''))
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
|
|
35
|
+
const checkpoints = [];
|
|
36
|
+
|
|
37
|
+
for (const branch of branches) {
|
|
38
|
+
try {
|
|
39
|
+
const log = git(`log ${branch} --oneline -1`, projectRoot);
|
|
40
|
+
const [commit, ...messageParts] = log.split(' ');
|
|
41
|
+
const message = messageParts.join(' ');
|
|
42
|
+
|
|
43
|
+
// Extract session info from branch name: shit/<commit>-<session>
|
|
44
|
+
const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
|
|
45
|
+
if (match) {
|
|
46
|
+
const [, baseCommit, sessionShort] = match;
|
|
47
|
+
checkpoints.push({
|
|
48
|
+
branch,
|
|
49
|
+
commit,
|
|
50
|
+
baseCommit,
|
|
51
|
+
sessionShort,
|
|
52
|
+
message,
|
|
53
|
+
timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Skip invalid branches
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return checkpoints.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getCurrentCommit(projectRoot) {
|
|
68
|
+
try {
|
|
69
|
+
return git('rev-parse HEAD', projectRoot);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasUncommittedChanges(projectRoot) {
|
|
76
|
+
try {
|
|
77
|
+
const status = git('status --porcelain', projectRoot);
|
|
78
|
+
return status.length > 0;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rewindToCheckpoint(projectRoot, checkpoint, force = false) {
|
|
85
|
+
const currentCommit = getCurrentCommit(projectRoot);
|
|
86
|
+
|
|
87
|
+
if (!force && hasUncommittedChanges(projectRoot)) {
|
|
88
|
+
throw new Error('Working directory has uncommitted changes. Use --force to override or commit changes first.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Reset to the base commit of the checkpoint
|
|
92
|
+
git(`reset --hard ${checkpoint.baseCommit}`, projectRoot);
|
|
93
|
+
|
|
94
|
+
console.log(`✅ Rewound to checkpoint ${checkpoint.sessionShort}`);
|
|
95
|
+
console.log(` Base commit: ${checkpoint.baseCommit}`);
|
|
96
|
+
console.log(` Previous HEAD: ${currentCommit}`);
|
|
97
|
+
|
|
98
|
+
return currentCommit;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default async function rewind(args) {
|
|
102
|
+
try {
|
|
103
|
+
const projectRoot = findProjectRoot();
|
|
104
|
+
const force = args.includes('--force') || args.includes('-f');
|
|
105
|
+
const interactive = args.includes('--interactive') || args.includes('-i');
|
|
106
|
+
|
|
107
|
+
// Get target checkpoint
|
|
108
|
+
let targetCheckpoint = null;
|
|
109
|
+
const checkpointArg = args.find(arg => !arg.startsWith('-'));
|
|
110
|
+
|
|
111
|
+
const checkpoints = listCheckpoints(projectRoot);
|
|
112
|
+
|
|
113
|
+
if (checkpoints.length === 0) {
|
|
114
|
+
console.log('❌ No checkpoints found');
|
|
115
|
+
console.log(' Checkpoints are created automatically when sessions end.');
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (checkpointArg) {
|
|
120
|
+
// Find specific checkpoint
|
|
121
|
+
targetCheckpoint = checkpoints.find(cp =>
|
|
122
|
+
cp.sessionShort.startsWith(checkpointArg) ||
|
|
123
|
+
cp.baseCommit.startsWith(checkpointArg)
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (!targetCheckpoint) {
|
|
127
|
+
console.error(`❌ Checkpoint not found: ${checkpointArg}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
} else if (interactive) {
|
|
131
|
+
// Interactive selection
|
|
132
|
+
console.log('📋 Available checkpoints:\n');
|
|
133
|
+
checkpoints.forEach((cp, i) => {
|
|
134
|
+
const date = new Date(cp.timestamp).toLocaleString();
|
|
135
|
+
console.log(`${i + 1}. ${cp.sessionShort} (${cp.baseCommit.slice(0, 7)}) - ${date}`);
|
|
136
|
+
console.log(` ${cp.message}`);
|
|
137
|
+
console.log();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// For now, just use the most recent
|
|
141
|
+
targetCheckpoint = checkpoints[0];
|
|
142
|
+
console.log(`Using most recent checkpoint: ${targetCheckpoint.sessionShort}`);
|
|
143
|
+
} else {
|
|
144
|
+
// Use most recent checkpoint
|
|
145
|
+
targetCheckpoint = checkpoints[0];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log(`🔄 Rewinding to checkpoint: ${targetCheckpoint.sessionShort}`);
|
|
149
|
+
console.log(` Branch: ${targetCheckpoint.branch}`);
|
|
150
|
+
console.log(` Base commit: ${targetCheckpoint.baseCommit}`);
|
|
151
|
+
console.log(` Created: ${new Date(targetCheckpoint.timestamp).toLocaleString()}`);
|
|
152
|
+
console.log();
|
|
153
|
+
|
|
154
|
+
if (!force && hasUncommittedChanges(projectRoot)) {
|
|
155
|
+
console.error('❌ Working directory has uncommitted changes.');
|
|
156
|
+
console.error(' Commit your changes or use --force to discard them.');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const previousCommit = rewindToCheckpoint(projectRoot, targetCheckpoint, force);
|
|
161
|
+
|
|
162
|
+
console.log();
|
|
163
|
+
console.log('💡 To undo this rewind:');
|
|
164
|
+
console.log(` git reset --hard ${previousCommit}`);
|
|
165
|
+
console.log();
|
|
166
|
+
console.log('💡 To resume from this checkpoint:');
|
|
167
|
+
console.log(` shit resume ${targetCheckpoint.sessionShort}`);
|
|
168
|
+
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('❌ Failed to rewind:', error.message);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
}
|
package/lib/status.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Show current session status
|
|
5
|
+
* Similar to 'entire status' - displays active session info
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
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
|
+
}
|
|
22
|
+
|
|
23
|
+
function getCurrentSession(projectRoot) {
|
|
24
|
+
const shitLogsDir = join(projectRoot, '.shit-logs');
|
|
25
|
+
if (!existsSync(shitLogsDir)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Find the most recent session directory
|
|
30
|
+
const { readdirSync, statSync } = await import('fs');
|
|
31
|
+
const sessions = readdirSync(shitLogsDir)
|
|
32
|
+
.filter(name => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/)) // session format
|
|
33
|
+
.map(name => ({
|
|
34
|
+
name,
|
|
35
|
+
path: join(shitLogsDir, name),
|
|
36
|
+
mtime: statSync(join(shitLogsDir, name)).mtime
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
39
|
+
|
|
40
|
+
if (sessions.length === 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const latestSession = sessions[0];
|
|
45
|
+
const stateFile = join(latestSession.path, 'state.json');
|
|
46
|
+
|
|
47
|
+
if (!existsSync(stateFile)) {
|
|
48
|
+
return { ...latestSession, state: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
53
|
+
return { ...latestSession, state };
|
|
54
|
+
} catch {
|
|
55
|
+
return { ...latestSession, state: null };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getGitInfo(projectRoot) {
|
|
60
|
+
try {
|
|
61
|
+
const branch = execSync('git branch --show-current', { cwd: projectRoot, encoding: 'utf-8' }).trim();
|
|
62
|
+
const commit = execSync('git rev-parse --short HEAD', { cwd: projectRoot, encoding: 'utf-8' }).trim();
|
|
63
|
+
const status = execSync('git status --porcelain', { cwd: projectRoot, encoding: 'utf-8' }).trim();
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
branch,
|
|
67
|
+
commit,
|
|
68
|
+
dirty: status.length > 0,
|
|
69
|
+
changes: status.split('\n').filter(Boolean).length
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatDuration(startTime) {
|
|
77
|
+
if (!startTime) return 'unknown';
|
|
78
|
+
|
|
79
|
+
const start = new Date(startTime);
|
|
80
|
+
const now = new Date();
|
|
81
|
+
const diff = now - start;
|
|
82
|
+
|
|
83
|
+
const minutes = Math.floor(diff / 60000);
|
|
84
|
+
const hours = Math.floor(minutes / 60);
|
|
85
|
+
|
|
86
|
+
if (hours > 0) {
|
|
87
|
+
return `${hours}h ${minutes % 60}m`;
|
|
88
|
+
} else {
|
|
89
|
+
return `${minutes}m`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default async function status(args) {
|
|
94
|
+
try {
|
|
95
|
+
const projectRoot = findProjectRoot();
|
|
96
|
+
const gitInfo = getGitInfo(projectRoot);
|
|
97
|
+
const currentSession = await getCurrentSession(projectRoot);
|
|
98
|
+
|
|
99
|
+
console.log('📊 shit-cli Status\n');
|
|
100
|
+
|
|
101
|
+
// Git info
|
|
102
|
+
if (gitInfo) {
|
|
103
|
+
console.log(`📂 Repository: ${gitInfo.branch} @ ${gitInfo.commit}`);
|
|
104
|
+
if (gitInfo.dirty) {
|
|
105
|
+
console.log(`⚠️ Working tree: ${gitInfo.changes} uncommitted changes`);
|
|
106
|
+
} else {
|
|
107
|
+
console.log('✅ Working tree: clean');
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
console.log('❌ Git repository: not found');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log();
|
|
114
|
+
|
|
115
|
+
// Session info
|
|
116
|
+
if (currentSession) {
|
|
117
|
+
console.log(`🎯 Current Session: ${currentSession.name}`);
|
|
118
|
+
|
|
119
|
+
if (currentSession.state) {
|
|
120
|
+
const state = currentSession.state;
|
|
121
|
+
console.log(` Started: ${new Date(state.start_time).toLocaleString()}`);
|
|
122
|
+
console.log(` Duration: ${formatDuration(state.start_time)}`);
|
|
123
|
+
console.log(` Events: ${state.event_count || 0}`);
|
|
124
|
+
console.log(` Files: ${Object.keys(state.files || {}).length}`);
|
|
125
|
+
|
|
126
|
+
if (state.shadow_branch) {
|
|
127
|
+
console.log(` Shadow: ${state.shadow_branch}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (state.session_type) {
|
|
131
|
+
console.log(` Type: ${state.session_type}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (state.risk_level) {
|
|
135
|
+
const riskEmoji = state.risk_level === 'high' ? '🔴' :
|
|
136
|
+
state.risk_level === 'medium' ? '🟡' : '🟢';
|
|
137
|
+
console.log(` Risk: ${riskEmoji} ${state.risk_level}`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
console.log(' State: no state file found');
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
console.log('💤 No active session found');
|
|
144
|
+
console.log(' Run "shit enable" to start tracking sessions');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check if shit-cli is enabled
|
|
148
|
+
const claudeSettings = join(projectRoot, '.claude', 'settings.json');
|
|
149
|
+
if (existsSync(claudeSettings)) {
|
|
150
|
+
try {
|
|
151
|
+
const settings = JSON.parse(readFileSync(claudeSettings, 'utf-8'));
|
|
152
|
+
const hasHooks = settings.hooks &&
|
|
153
|
+
(settings.hooks.session_start || settings.hooks.session_end);
|
|
154
|
+
|
|
155
|
+
console.log();
|
|
156
|
+
if (hasHooks) {
|
|
157
|
+
console.log('✅ shit-cli: enabled and configured');
|
|
158
|
+
} else {
|
|
159
|
+
console.log('⚠️ shit-cli: not configured (run "shit enable")');
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
console.log('⚠️ shit-cli: configuration error');
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
console.log();
|
|
166
|
+
console.log('❌ shit-cli: not enabled (run "shit enable")');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('❌ Failed to get status:', error.message);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
}
|