Skip to content

Commit 32a9f41

Browse files
committed
feat(checkpoints): surface non-git workdir with a helpful hint
/api/sessions/{name}/checkpoints response is now {git_workdir: bool, checkpoints: Checkpoint[]}. When the workdir has no .git dir, the handler short-circuits before touching git and returns git_workdir:false; the UI renders an explanatory panel on the Checkpoints tab instead of a silent empty list, pointing the user at 'git init'. Mocks and checkpoint-diff e2e updated to match the new shape.
1 parent d6287e5 commit 32a9f41

6 files changed

Lines changed: 150 additions & 40 deletions

File tree

internal/serve/api/checkpoints.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package api
33
import (
44
"encoding/json"
55
"net/http"
6+
"os"
7+
"path/filepath"
68
"strconv"
79
"sync"
810
"time"
@@ -141,10 +143,20 @@ func Checkpoints(resolveWorkdir func(name string) (string, bool), cache *Checkpo
141143
}
142144
}
143145

146+
gitDir := isGitWorkdir(workdir)
147+
w.Header().Set("Content-Type", "application/json")
148+
w.Header().Set("Cache-Control", "no-store")
149+
150+
if !gitDir {
151+
_ = json.NewEncoder(w).Encode(checkpointsResp{
152+
GitWorkdir: false,
153+
Checkpoints: []git.Checkpoint{},
154+
})
155+
return
156+
}
157+
144158
list, err := cache.Get(workdir, name, limit)
145159
if err != nil {
146-
w.Header().Set("Content-Type", "application/json")
147-
w.Header().Set("Cache-Control", "no-store")
148160
w.WriteHeader(http.StatusInternalServerError)
149161
_ = json.NewEncoder(w).Encode(map[string]string{"error": "git_failed"})
150162
return
@@ -153,8 +165,25 @@ func Checkpoints(resolveWorkdir func(name string) (string, bool), cache *Checkpo
153165
list = []git.Checkpoint{}
154166
}
155167

156-
w.Header().Set("Content-Type", "application/json")
157-
w.Header().Set("Cache-Control", "no-store")
158-
_ = json.NewEncoder(w).Encode(list)
168+
_ = json.NewEncoder(w).Encode(checkpointsResp{
169+
GitWorkdir: true,
170+
Checkpoints: list,
171+
})
172+
}
173+
}
174+
175+
type checkpointsResp struct {
176+
GitWorkdir bool `json:"git_workdir"`
177+
Checkpoints []git.Checkpoint `json:"checkpoints"`
178+
}
179+
180+
// isGitWorkdir reports whether workdir contains a `.git` entry (dir
181+
// or file — worktrees use a file). No stat = no git repo = no
182+
// checkpoints possible.
183+
func isGitWorkdir(workdir string) bool {
184+
if workdir == "" {
185+
return false
159186
}
187+
_, err := os.Stat(filepath.Join(workdir, ".git"))
188+
return err == nil
160189
}

internal/serve/api/checkpoints_test.go

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,27 @@ import (
44
"encoding/json"
55
"net/http"
66
"net/http/httptest"
7+
"os"
8+
"path/filepath"
79
"sync/atomic"
810
"testing"
911
"time"
1012

1113
"github.com/RandomCodeSpace/ctm/internal/serve/git"
1214
)
1315

16+
// gitWorkdir returns a tempdir with a .git directory so the handler's
17+
// isGitWorkdir check passes — otherwise the handler short-circuits
18+
// before calling the lister.
19+
func gitWorkdir(t *testing.T) string {
20+
t.Helper()
21+
dir := t.TempDir()
22+
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o700); err != nil {
23+
t.Fatal(err)
24+
}
25+
return dir
26+
}
27+
1428
func TestCheckpoints_404OnUnknownSession(t *testing.T) {
1529
h := Checkpoints(func(name string) (string, bool) { return "", false }, nil)
1630
rec := httptest.NewRecorder()
@@ -42,7 +56,8 @@ func TestCheckpoints_CacheHitWithin5s(t *testing.T) {
4256
return want, nil
4357
}
4458

45-
h := Checkpoints(func(name string) (string, bool) { return "/fake/wd", true }, nil)
59+
wd := gitWorkdir(t)
60+
h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil)
4661

4762
for i := 0; i < 5; i++ {
4863
rec := httptest.NewRecorder()
@@ -52,11 +67,14 @@ func TestCheckpoints_CacheHitWithin5s(t *testing.T) {
5267
if rec.Code != http.StatusOK {
5368
t.Fatalf("call %d: status = %d", i, rec.Code)
5469
}
55-
var got []git.Checkpoint
70+
var got checkpointsResp
5671
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
5772
t.Fatalf("decode: %v", err)
5873
}
59-
if len(got) != 1 || got[0].SHA != "abc" {
74+
if !got.GitWorkdir {
75+
t.Fatalf("call %d: git_workdir = false, want true", i)
76+
}
77+
if len(got.Checkpoints) != 1 || got.Checkpoints[0].SHA != "abc" {
6078
t.Errorf("call %d: payload = %+v", i, got)
6179
}
6280
}
@@ -74,7 +92,8 @@ func TestCheckpoints_CacheKeyedOnLimit(t *testing.T) {
7492
atomic.AddInt32(&calls, 1)
7593
return nil, nil
7694
}
77-
h := Checkpoints(func(name string) (string, bool) { return "/wd", true }, nil)
95+
wd := gitWorkdir(t)
96+
h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil)
7897

7998
for _, q := range []string{"", "?limit=10", "?limit=10", "?limit=20"} {
8099
rec := httptest.NewRecorder()
@@ -94,14 +113,56 @@ func TestCheckpoints_NilListEncodedAsEmptyArray(t *testing.T) {
94113
checkpointsLister = func(workdir string, limit int) ([]git.Checkpoint, error) {
95114
return nil, nil
96115
}
97-
h := Checkpoints(func(name string) (string, bool) { return "/wd", true }, nil)
116+
wd := gitWorkdir(t)
117+
h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil)
98118
rec := httptest.NewRecorder()
99119
req := httptest.NewRequest(http.MethodGet, "/api/sessions/s/checkpoints", nil)
100120
req.SetPathValue("name", "s")
101121
h(rec, req)
102-
body := rec.Body.String()
103-
if body != "[]\n" {
104-
t.Errorf("body = %q, want \"[]\\n\"", body)
122+
var got checkpointsResp
123+
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
124+
t.Fatalf("decode: %v", err)
125+
}
126+
if !got.GitWorkdir {
127+
t.Fatalf("git_workdir = false, want true")
128+
}
129+
if got.Checkpoints == nil || len(got.Checkpoints) != 0 {
130+
t.Errorf("checkpoints = %+v, want empty non-nil slice", got.Checkpoints)
131+
}
132+
}
133+
134+
func TestCheckpoints_NotGitWorkdir(t *testing.T) {
135+
// No .git dir — handler must short-circuit and return
136+
// git_workdir:false without calling the lister.
137+
prev := checkpointsLister
138+
t.Cleanup(func() { checkpointsLister = prev })
139+
var calls int32
140+
checkpointsLister = func(workdir string, limit int) ([]git.Checkpoint, error) {
141+
atomic.AddInt32(&calls, 1)
142+
return nil, nil
143+
}
144+
145+
wd := t.TempDir() // no .git subdir
146+
h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil)
147+
rec := httptest.NewRecorder()
148+
req := httptest.NewRequest(http.MethodGet, "/api/sessions/s/checkpoints", nil)
149+
req.SetPathValue("name", "s")
150+
h(rec, req)
151+
if rec.Code != http.StatusOK {
152+
t.Fatalf("status = %d, want 200", rec.Code)
153+
}
154+
var got checkpointsResp
155+
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
156+
t.Fatalf("decode: %v", err)
157+
}
158+
if got.GitWorkdir {
159+
t.Errorf("git_workdir = true, want false for non-git workdir")
160+
}
161+
if len(got.Checkpoints) != 0 {
162+
t.Errorf("checkpoints = %+v, want empty", got.Checkpoints)
163+
}
164+
if c := atomic.LoadInt32(&calls); c != 0 {
165+
t.Errorf("lister called %d times for non-git workdir, want 0", c)
105166
}
106167
}
107168

@@ -118,8 +179,6 @@ func TestCheckpointsCache_IsCheckpointFullSHAOnly(t *testing.T) {
118179
if !cache.IsCheckpoint("/wd", "name", fullSHA) {
119180
t.Error("full SHA must be allowed")
120181
}
121-
// Abbreviated SHAs (UI might naively round-trip a 7-char display
122-
// SHA) must be rejected to keep allowlist surface area minimal.
123182
if cache.IsCheckpoint("/wd", "name", fullSHA[:7]) {
124183
t.Error("7-char abbreviated SHA must be rejected")
125184
}
@@ -140,13 +199,9 @@ func TestCheckpoints_CacheExpiresAfterTTL(t *testing.T) {
140199
atomic.AddInt32(&calls, 1)
141200
return nil, nil
142201
}
143-
h := Checkpoints(func(name string) (string, bool) { return "/wd", true }, nil)
202+
wd := gitWorkdir(t)
203+
h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil)
144204

145-
// First call populates cache; manually expire the entry by reaching
146-
// into the closure-captured cache via a real handler call before
147-
// stale time, then asserting we miss after TTL by directly poking
148-
// time isn't possible without a clock seam — instead we verify the
149-
// hit-then-miss path by relying on TTL only when CTM_LONG_TESTS=1.
150205
rec1 := httptest.NewRecorder()
151206
r1 := httptest.NewRequest(http.MethodGet, "/api/sessions/s/checkpoints", nil)
152207
r1.SetPathValue("name", "s")
@@ -158,5 +213,5 @@ func TestCheckpoints_CacheExpiresAfterTTL(t *testing.T) {
158213
if c := atomic.LoadInt32(&calls); c != 1 {
159214
t.Fatalf("cache miss within TTL: calls = %d", c)
160215
}
161-
_ = time.Now() // keep import even if long-test branch removed
216+
_ = time.Now()
162217
}

ui/e2e/checkpoint-diff.spec.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,27 @@ test.describe("Checkpoint diff viewer", () => {
2323

2424
// Checkpoint list: one commit; the "View diff" button's rendered
2525
// SHA will be derived from this row's `short_sha`.
26-
await page.route("**/api/sessions/alpha/checkpoints**", (route: Route) =>
27-
route.fulfill({
26+
await page.route("**/api/sessions/alpha/checkpoints**", (route: Route) => {
27+
// Diff endpoint also matches this glob (path includes /checkpoints/<sha>/diff).
28+
// Fall through to the specific diff route when the path is deeper.
29+
const url = new URL(route.request().url());
30+
if (!url.pathname.endsWith("/checkpoints")) return route.fallback();
31+
return route.fulfill({
2832
contentType: "application/json",
29-
body: JSON.stringify([
30-
{
31-
sha: FULL_SHA,
32-
short_sha: FULL_SHA.slice(0, 7),
33-
subject: "checkpoint: pre-yolo 2026-04-21T12:00:00",
34-
author: "ctm",
35-
ts: new Date(Date.now() - 60_000).toISOString(),
36-
},
37-
]),
38-
}),
39-
);
33+
body: JSON.stringify({
34+
git_workdir: true,
35+
checkpoints: [
36+
{
37+
sha: FULL_SHA,
38+
short_sha: FULL_SHA.slice(0, 7),
39+
subject: "checkpoint: pre-yolo 2026-04-21T12:00:00",
40+
author: "ctm",
41+
ts: new Date(Date.now() - 60_000).toISOString(),
42+
},
43+
],
44+
}),
45+
});
46+
});
4047

4148
// Diff endpoint. Note the path order differs from /checkpoints so
4249
// this route won't collide with the list mock above.

ui/e2e/fixtures/mocks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export async function installMocks(
7373
} else if (path.endsWith("/teams")) {
7474
body = JSON.stringify({ teams: [] });
7575
} else if (path.endsWith("/checkpoints")) {
76-
body = "[]";
76+
body = JSON.stringify({ git_workdir: true, checkpoints: [] });
7777
} else if (path.endsWith("/feed/history")) {
7878
body = JSON.stringify({ events: [], has_more: false });
7979
} else if (path === "/api/logs/usage") {

ui/src/hooks/useCheckpoints.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@ export interface Checkpoint {
99
short_sha: string;
1010
}
1111

12+
export interface CheckpointsResponse {
13+
git_workdir: boolean;
14+
checkpoints: Checkpoint[];
15+
}
16+
1217
export function useCheckpoints(sessionName: string | undefined, limit = 50) {
13-
return useQuery<Checkpoint[]>({
18+
return useQuery<CheckpointsResponse>({
1419
queryKey: ["checkpoints", sessionName, limit],
1520
queryFn: () =>
16-
api<Checkpoint[]>(
21+
api<CheckpointsResponse>(
1722
`/api/sessions/${encodeURIComponent(sessionName!)}/checkpoints?limit=${limit}`,
1823
),
1924
enabled: Boolean(sessionName),

ui/src/routes/SessionDetail.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,9 @@ function CheckpointsTab({ sessionName }: { sessionName: string }) {
356356
// flow are fully independent — closing one must not affect the other.
357357
const [diffTarget, setDiffTarget] = useState<Checkpoint | null>(null);
358358

359+
const checkpoints = data?.checkpoints ?? [];
360+
const isGitWorkdir = data?.git_workdir ?? true;
361+
359362
return (
360363
<>
361364
<div className="min-h-0 flex-1 overflow-y-auto">
@@ -374,13 +377,24 @@ function CheckpointsTab({ sessionName }: { sessionName: string }) {
374377
{error instanceof Error ? `: ${error.message}` : ""}
375378
</p>
376379
)}
377-
{!isLoading && !isError && (data ?? []).length === 0 && (
380+
{!isLoading && !isError && !isGitWorkdir && (
381+
<div className="m-4 border-l-[3px] border-accent-gold bg-surface px-3 py-3 text-sm text-fg">
382+
<div className="font-semibold">Checkpoints need a git repo</div>
383+
<p className="mt-1 text-[12px] text-fg-dim">
384+
This session&apos;s workdir isn&apos;t a git repository, so ctm has nothing to snapshot.
385+
Run{" "}
386+
<code className="rounded bg-surface-2 px-1 py-0.5 font-mono">git init</code>{" "}
387+
in the workdir to enable pre-yolo checkpoints on the next tool call.
388+
</p>
389+
</div>
390+
)}
391+
{!isLoading && !isError && isGitWorkdir && checkpoints.length === 0 && (
378392
<p className="px-4 py-8 text-center text-sm text-fg-dim">
379393
No checkpoints. Run ctm yolo to create the first.
380394
</p>
381395
)}
382396
<ul role="list">
383-
{(data ?? []).map((cp) => (
397+
{checkpoints.map((cp) => (
384398
<li key={cp.sha}>
385399
<CheckpointRow
386400
checkpoint={cp}

0 commit comments

Comments
 (0)