@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/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, rmSync, readdirSync, statSync } from 'fs';
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() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
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() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
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() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
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 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) {
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} (started ${startTime.toLocaleString()})`,
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 = findProjectRoot();
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) 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 = findProjectRoot();
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 { execSync } from 'child_process';
10
+ import { execFileSync } from 'child_process';
11
+ import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
11
12
 
12
- function findProjectRoot() {
13
- let dir = process.cwd();
14
- while (dir !== '/') {
15
- if (existsSync(join(dir, '.git'))) {
16
- return dir;
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
- dir = join(dir, '..');
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 = execSync('git branch --list "shit/checkpoints/v1/*"', {
117
- cwd: projectRoot,
118
- encoding: 'utf-8'
119
- }).split('\n').filter(Boolean);
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 = execSync(`git log ${branch} --oneline -1`, {
124
- cwd: projectRoot,
125
- encoding: 'utf-8'
126
- }).trim();
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 = execSync(`git log ${branch} --format=%B -1`, {
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 = findProjectRoot();
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 2026-02-28-abc12345 # Explain a session');
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
- try {
174
- execSync('git rev-parse --verify ' + target + '^{commit}', {
175
- cwd: projectRoot,
176
- stdio: 'ignore'
177
- });
178
-
179
- console.log('📸 Commit Explanation\n');
180
- console.log(explainFromCommit(target, projectRoot));
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 sessions = execSync('ls .shit-logs', { cwd: projectRoot, encoding: 'utf-8' })
188
- .split('\n')
189
- .filter(s => s.includes(target));
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, rmSync } from 'fs';
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 => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/))
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 = findProjectRoot();
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
- require('fs').writeFileSync(stateFile, JSON.stringify(state, null, 2));
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
- 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 { 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 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);
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
- 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}`;
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 = findProjectRoot();
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 shadow" to list available checkpoints');
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
- // Get shadow branches
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 branches) {
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
- // 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
- }
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 = findProjectRoot();
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 automatically when sessions end.');
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.baseCommit.startsWith(checkpointArg)
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
- console.log(`${i + 1}. ${cp.sessionShort} (${cp.baseCommit.slice(0, 7)}) - ${date}`);
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
- console.log(`🔄 Rewinding to checkpoint: ${targetCheckpoint.sessionShort}`);
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 ${targetCheckpoint.sessionShort}`);
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;