Skip to content

Commit 9096d0b

Browse files
Add hardening coverage for adoption-critical paths
Co-authored-by: Andrew <andrewxhill@gmail.com>
1 parent 06e4f64 commit 9096d0b

6 files changed

Lines changed: 449 additions & 0 deletions

File tree

PLAN.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,11 @@ Must-have scenarios:
914914
- branch blocks on conflict and leaves protected branch unchanged
915915
- upstream advances before integration and queue syncs correctly
916916
- protected branch advances twice before publish and only latest matters
917+
- concurrent multi-worktree actors preserve queue ordering
918+
- deleted, moved, relinked, or dirtied source worktrees fail safely after submit
919+
- inherited pre-push hooks fire on publish success and failure-plus-retry flows
920+
- `status --json`, lifecycle `events --json`, and daemon JSON logs keep a pinned contract
921+
- bare-repo plus linked-worktree daemon cycles still operate after temp-dir relocation
917922

918923
### Soak Tests
919924

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ path.
141141
- policy checks, hook coordination, and repo-managed hooks
142142
- Homebrew, Nix, GitHub release archives, checksums, and release manifests
143143

144+
Recent hardening coverage now explicitly exercises the adoption-critical paths:
145+
146+
- concurrent multi-worktree submit, integrate, and publish flows
147+
- deleted, moved, relinked, and dirtied source worktrees after submit
148+
- external protected-branch advancement while queued work waits
149+
- inherited `pre-push` hook success and failure-plus-retry publish paths
150+
- JSON contract tests for `status`, lifecycle `events`, and daemon logs
151+
- bare-repo plus linked-worktree daemon runs
152+
144153
This repo dogfoods that workflow. The repo-local worktree instructions live in
145154
[.agents/skills/worktree/SKILL.md](/Users/devrel/Projects/recallnet/mainline/.agents/skills/worktree/SKILL.md),
146155
and the repo-specific guardrails live in

internal/app/app_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,159 @@ func TestGlobalJSONForwardsToRunOnceAndPublish(t *testing.T) {
165165
}
166166
}
167167

168+
func TestStatusJSONContractContainsStableTopLevelFields(t *testing.T) {
169+
repoRoot, _ := createTestRepoWithRemote(t)
170+
initRepoForWorker(t, repoRoot)
171+
172+
var stdout bytes.Buffer
173+
var stderr bytes.Buffer
174+
if err := runCLI([]string{"status", "--repo", repoRoot, "--json"}, &stdout, &stderr); err != nil {
175+
t.Fatalf("runCLI returned error: %v", err)
176+
}
177+
178+
var payload map[string]any
179+
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
180+
t.Fatalf("Unmarshal: %v", err)
181+
}
182+
required := []string{
183+
"repository_root",
184+
"state_path",
185+
"current_worktree",
186+
"current_branch",
187+
"protected_branch",
188+
"protected_branch_sha",
189+
"protected_upstream",
190+
"counts",
191+
"recent_events",
192+
}
193+
for _, key := range required {
194+
if _, ok := payload[key]; !ok {
195+
t.Fatalf("expected status json key %q in %+v", key, payload)
196+
}
197+
}
198+
}
199+
200+
func TestEventsLifecycleJSONContractContainsStableFields(t *testing.T) {
201+
repoRoot, _ := createTestRepoWithRemote(t)
202+
initRepoForWorker(t, repoRoot)
203+
204+
featurePath := filepath.Join(t.TempDir(), "feature-events-contract")
205+
runTestCommand(t, repoRoot, "git", "worktree", "add", "-b", "feature/events-contract", featurePath)
206+
writeFileAndCommit(t, featurePath, "events.txt", "events\n", "events feature")
207+
submitBranch(t, featurePath)
208+
runOnce(t, repoRoot)
209+
210+
var stdout bytes.Buffer
211+
var stderr bytes.Buffer
212+
if err := runCLI([]string{"events", "--repo", repoRoot, "--json", "--lifecycle", "--limit", "20"}, &stdout, &stderr); err != nil {
213+
t.Fatalf("runCLI returned error: %v", err)
214+
}
215+
216+
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
217+
if len(lines) == 0 {
218+
t.Fatalf("expected lifecycle json lines")
219+
}
220+
221+
foundIntegrated := false
222+
for _, line := range lines {
223+
if strings.TrimSpace(line) == "" {
224+
continue
225+
}
226+
var payload map[string]any
227+
if err := json.Unmarshal([]byte(line), &payload); err != nil {
228+
t.Fatalf("Unmarshal line %q: %v", line, err)
229+
}
230+
for _, key := range []string{"event", "repository_root", "timestamp"} {
231+
if _, ok := payload[key]; !ok {
232+
t.Fatalf("expected lifecycle key %q in %+v", key, payload)
233+
}
234+
}
235+
if payload["event"] == "integrated" {
236+
foundIntegrated = true
237+
for _, key := range []string{"branch", "sha", "submission_id"} {
238+
if _, ok := payload[key]; !ok {
239+
t.Fatalf("expected integrated lifecycle key %q in %+v", key, payload)
240+
}
241+
}
242+
}
243+
}
244+
if !foundIntegrated {
245+
t.Fatalf("expected integrated lifecycle event in %q", stdout.String())
246+
}
247+
}
248+
249+
func TestDaemonJSONLogContractContainsStableFields(t *testing.T) {
250+
repoRoot, _ := createTestRepoWithRemote(t)
251+
initRepoForWorker(t, repoRoot)
252+
253+
var stdout bytes.Buffer
254+
opts := daemonOptions{
255+
repoPath: repoRoot,
256+
interval: time.Millisecond,
257+
jsonLogs: true,
258+
idleExit: true,
259+
}
260+
if err := runDaemonLoop(context.Background(), opts, &stdout); err != nil {
261+
t.Fatalf("runDaemonLoop returned error: %v", err)
262+
}
263+
264+
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
265+
if len(lines) < 2 {
266+
t.Fatalf("expected daemon json log lines, got %q", stdout.String())
267+
}
268+
for _, line := range lines {
269+
var payload map[string]any
270+
if err := json.Unmarshal([]byte(line), &payload); err != nil {
271+
t.Fatalf("Unmarshal line %q: %v", line, err)
272+
}
273+
for _, key := range []string{"level", "event", "repo", "timestamp"} {
274+
if _, ok := payload[key]; !ok {
275+
t.Fatalf("expected daemon log key %q in %+v", key, payload)
276+
}
277+
}
278+
}
279+
}
280+
281+
func TestDaemonProcessesWorkFromBareCloneLinkedWorktree(t *testing.T) {
282+
bareDir, worktreePath := createBareCloneWorktree(t)
283+
284+
var initOut bytes.Buffer
285+
var initErr bytes.Buffer
286+
if err := runRepoInit([]string{"--repo", worktreePath}, &initOut, &initErr); err != nil {
287+
t.Fatalf("runRepoInit returned error: %v", err)
288+
}
289+
featurePath := filepath.Join(t.TempDir(), "bare-feature")
290+
runTestCommand(t, worktreePath, "git", "worktree", "add", "-b", "feature/bare-daemon", featurePath)
291+
writeFileAndCommit(t, featurePath, "bare.txt", "bare\n", "bare feature")
292+
submitBranch(t, featurePath)
293+
294+
var stdout bytes.Buffer
295+
opts := daemonOptions{
296+
repoPath: worktreePath,
297+
interval: time.Millisecond,
298+
maxCycles: 1,
299+
}
300+
if err := runDaemonLoop(context.Background(), opts, &stdout); err != nil {
301+
t.Fatalf("runDaemonLoop returned error: %v", err)
302+
}
303+
304+
if _, err := os.Stat(filepath.Join(worktreePath, "bare.txt")); err != nil {
305+
t.Fatalf("expected bare.txt after daemon integration: %v", err)
306+
}
307+
308+
layout, err := git.DiscoverRepositoryLayout(worktreePath)
309+
if err != nil {
310+
t.Fatalf("DiscoverRepositoryLayout: %v", err)
311+
}
312+
wantBareDir, err := filepath.EvalSymlinks(bareDir)
313+
if err != nil {
314+
t.Fatalf("EvalSymlinks(bareDir): %v", err)
315+
}
316+
if layout.RepositoryRoot != wantBareDir {
317+
t.Fatalf("expected bare repository root %q, got %q", wantBareDir, layout.RepositoryRoot)
318+
}
319+
}
320+
168321
func TestDaemonProcessesIntegrationAndPublishWork(t *testing.T) {
169322
repoRoot, remoteDir := createTestRepoWithRemote(t)
170323
initRepoForWorker(t, repoRoot)

internal/app/publish_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,117 @@ func TestPublishRespectsHookPolicyBypassingPrePushHook(t *testing.T) {
443443
}
444444
}
445445

446+
func TestRunOncePublishesWithInheritedPrePushHook(t *testing.T) {
447+
repoRoot, remoteDir := createTestRepoWithRemote(t)
448+
initRepoForWorker(t, repoRoot)
449+
450+
hookMarker := filepath.Join(t.TempDir(), "pre-push-ran")
451+
hookPath := filepath.Join(hooksDirForRepo(t, repoRoot), "pre-push")
452+
if err := os.WriteFile(hookPath, []byte("#!/bin/sh\necho hook > "+hookMarker+"\nexit 0\n"), 0o755); err != nil {
453+
t.Fatalf("WriteFile: %v", err)
454+
}
455+
456+
cfg, _, err := policy.LoadOrDefault(repoRoot)
457+
if err != nil {
458+
t.Fatalf("LoadOrDefault: %v", err)
459+
}
460+
if cfg.Repo.HookPolicy != "inherit" {
461+
t.Fatalf("expected default hook policy inherit, got %+v", cfg.Repo.HookPolicy)
462+
}
463+
464+
writeFileAndCommit(t, repoRoot, "inherit.txt", "inherit\n", "main change inherit")
465+
localHead := trimNewline(runTestCommand(t, repoRoot, "git", "rev-parse", "HEAD"))
466+
queuePublish(t, repoRoot)
467+
runOnce(t, repoRoot)
468+
469+
if payload, err := os.ReadFile(hookMarker); err != nil || strings.TrimSpace(string(payload)) != "hook" {
470+
t.Fatalf("expected inherited pre-push hook marker, got %q err=%v", string(payload), err)
471+
}
472+
473+
remoteHead := trimNewline(runTestCommand(t, remoteDir, "git", "rev-parse", "refs/heads/main"))
474+
if remoteHead != localHead {
475+
t.Fatalf("expected remote head %q, got %q", localHead, remoteHead)
476+
}
477+
}
478+
479+
func TestPublishHookFailureThenManualRetrySucceeds(t *testing.T) {
480+
repoRoot, remoteDir := createTestRepoWithRemote(t)
481+
initRepoForWorker(t, repoRoot)
482+
483+
gateFlag := filepath.Join(t.TempDir(), "allow-push")
484+
hookPath := filepath.Join(hooksDirForRepo(t, repoRoot), "pre-push")
485+
if err := os.WriteFile(hookPath, []byte("#!/bin/sh\nif [ ! -f "+gateFlag+" ]; then\n echo gate failed >&2\n exit 9\nfi\nexit 0\n"), 0o755); err != nil {
486+
t.Fatalf("WriteFile: %v", err)
487+
}
488+
489+
cfg, _, err := policy.LoadOrDefault(repoRoot)
490+
if err != nil {
491+
t.Fatalf("LoadOrDefault: %v", err)
492+
}
493+
if cfg.Repo.HookPolicy != "inherit" {
494+
t.Fatalf("expected default hook policy inherit, got %+v", cfg.Repo.HookPolicy)
495+
}
496+
497+
writeFileAndCommit(t, repoRoot, "retry-hook.txt", "retry hook\n", "main change retry hook")
498+
localHead := trimNewline(runTestCommand(t, repoRoot, "git", "rev-parse", "HEAD"))
499+
queuePublish(t, repoRoot)
500+
501+
result, err := runOneCycle(repoRoot)
502+
if err != nil {
503+
t.Fatalf("first runOneCycle returned error: %v", err)
504+
}
505+
if !strings.Contains(result, "Failed publish request") {
506+
t.Fatalf("expected failed publish output, got %q", result)
507+
}
508+
509+
layout, err := git.DiscoverRepositoryLayout(repoRoot)
510+
if err != nil {
511+
t.Fatalf("DiscoverRepositoryLayout: %v", err)
512+
}
513+
store := state.NewStore(state.DefaultPath(layout.GitDir))
514+
repoRecord, err := store.GetRepositoryByPath(context.Background(), layout.RepositoryRoot)
515+
if err != nil {
516+
t.Fatalf("GetRepositoryByPath: %v", err)
517+
}
518+
requests, err := store.ListPublishRequests(context.Background(), repoRecord.ID)
519+
if err != nil {
520+
t.Fatalf("ListPublishRequests: %v", err)
521+
}
522+
if len(requests) != 1 || requests[0].Status != "failed" {
523+
t.Fatalf("expected failed publish request, got %+v", requests)
524+
}
525+
526+
if err := os.WriteFile(gateFlag, []byte("ok\n"), 0o644); err != nil {
527+
t.Fatalf("WriteFile(gateFlag): %v", err)
528+
}
529+
530+
var retryOut bytes.Buffer
531+
var retryErr bytes.Buffer
532+
if err := runRetry([]string{"--repo", repoRoot, "--publish", strconv.FormatInt(requests[0].ID, 10)}, &retryOut, &retryErr); err != nil {
533+
t.Fatalf("runRetry returned error: %v", err)
534+
}
535+
536+
result, err = runOneCycle(repoRoot)
537+
if err != nil {
538+
t.Fatalf("second runOneCycle returned error: %v", err)
539+
}
540+
if !strings.Contains(result, "Published request") {
541+
t.Fatalf("expected publish success output after retry, got %q", result)
542+
}
543+
544+
remoteHead := trimNewline(runTestCommand(t, remoteDir, "git", "rev-parse", "refs/heads/main"))
545+
if remoteHead != localHead {
546+
t.Fatalf("expected remote head %q, got %q", localHead, remoteHead)
547+
}
548+
requests, err = store.ListPublishRequests(context.Background(), repoRecord.ID)
549+
if err != nil {
550+
t.Fatalf("ListPublishRequests: %v", err)
551+
}
552+
if requests[0].Status != "succeeded" {
553+
t.Fatalf("expected retried publish request to succeed, got %+v", requests[0])
554+
}
555+
}
556+
446557
func TestRunOnceCanPreemptInFlightPublishForNewerTarget(t *testing.T) {
447558
repoRoot, remoteDir := createTestRepoWithRemote(t)
448559
initRepoForWorker(t, repoRoot)

0 commit comments

Comments
 (0)