klasp-agents-codex 0.4.0

Codex agent surface for klasp — writes the AGENTS.md managed-block that documents the gate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
//! Integration test: drive `CodexSurface::install_detailed` over a
//! tempdir and prove the git-hook contract holds end-to-end.
//!
//! Covers the W2 (#28) acceptance items verbatim:
//!
//! 1. Generated hooks invoke `klasp gate --agent codex` with
//!    `KLASP_GATE_SCHEMA` exported on the same line.
//! 2. Pre-existing hook content is preserved by appending klasp's
//!    managed section.
//! 3. Conflict detection covers husky / lefthook / pre-commit framework
//!    (real-world fixtures under `tests/fixtures/githooks/`).
//! 4. Uninstall strips klasp's managed-section block from each hook
//!    without touching sibling content; collapses-to-shebang-only files
//!    are removed so the round-trip from missing-file install is clean.
//! 5. Fresh-create install + uninstall round-trips to a missing file.

use std::fs;
use std::path::{Path, PathBuf};

use klasp_agents_codex::{
    git_hooks::{self, HookConflict, HookKind, HookWarning, MANAGED_END, MANAGED_START},
    CodexSurface,
};
use klasp_core::{AgentSurface, InstallContext, GATE_SCHEMA_VERSION};

const FIXTURE_HUSKY: &str = include_str!("fixtures/githooks/pre-commit-husky.sh");
const FIXTURE_HUSKY_V9: &str = include_str!("fixtures/githooks/pre-commit-husky-v9.sh");
const FIXTURE_LEFTHOOK: &str = include_str!("fixtures/githooks/pre-commit-lefthook.sh");
const FIXTURE_PRECOMMIT_FRAMEWORK: &str =
    include_str!("fixtures/githooks/pre-commit-pre-commit-framework.sh");
const FIXTURE_USER_BASH: &str = include_str!("fixtures/githooks/pre-commit-user-bash.sh");

fn ctx(repo_root: PathBuf) -> InstallContext {
    InstallContext {
        repo_root,
        dry_run: false,
        force: false,
        schema_version: GATE_SCHEMA_VERSION,
    }
}

fn read(path: &Path) -> String {
    fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}

fn pre_commit(repo_root: &Path) -> PathBuf {
    repo_root.join(".git").join("hooks").join("pre-commit")
}

fn pre_push(repo_root: &Path) -> PathBuf {
    repo_root.join(".git").join("hooks").join("pre-push")
}

fn ensure_git_hooks_dir(repo_root: &Path) {
    fs::create_dir_all(repo_root.join(".git").join("hooks"))
        .expect("create .git/hooks/ for fixtures");
}

// ────────────────────────────────────────────────────────────────────
// Fresh-create paths
// ────────────────────────────────────────────────────────────────────

#[test]
fn install_fresh_creates_pre_commit_with_shebang_block_and_executable_bit() {
    let dir = tempfile::tempdir().unwrap();
    let surface = CodexSurface;

    surface.install(&ctx(dir.path().to_path_buf())).unwrap();

    let body = read(&pre_commit(dir.path()));
    assert!(
        body.starts_with("#!/usr/bin/env sh"),
        "fresh hook must start with portable shebang, got: {:?}",
        &body[..body.len().min(40)]
    );
    assert!(body.contains(MANAGED_START));
    assert!(body.contains(MANAGED_END));
    // Schema env-var must be exported on the same line as the exec
    // so the schema-mismatch path in `klasp gate` can detect drift.
    let expected_schema_export = format!("KLASP_GATE_SCHEMA={GATE_SCHEMA_VERSION} exec klasp gate");
    assert!(
        body.contains(&expected_schema_export),
        "schema export must precede the exec on the same line; got:\n{body}",
    );
    assert!(body.contains("--agent codex"));
    assert!(body.contains("--trigger commit"));
    assert!(body.contains("\"$@\""), "must propagate hook args");

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mode = fs::metadata(pre_commit(dir.path()))
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o755, "fresh hook must be executable, got {mode:o}");
    }
}

#[test]
fn install_fresh_creates_pre_push_with_push_trigger() {
    let dir = tempfile::tempdir().unwrap();
    let surface = CodexSurface;
    surface.install(&ctx(dir.path().to_path_buf())).unwrap();

    let body = read(&pre_push(dir.path()));
    assert!(body.starts_with("#!/usr/bin/env sh"));
    assert!(body.contains("--trigger push"));
    assert!(!body.contains("--trigger commit"));
}

#[test]
fn install_fresh_writes_three_paths_in_install_report() {
    let dir = tempfile::tempdir().unwrap();
    let surface = CodexSurface;
    let report = surface
        .install_detailed(&ctx(dir.path().to_path_buf()))
        .unwrap();

    let written: Vec<_> = report
        .report
        .paths_written
        .iter()
        .map(|p| p.file_name().unwrap().to_owned())
        .collect();
    assert!(written.iter().any(|n| n == "AGENTS.md"));
    assert!(written.iter().any(|n| n == "pre-commit"));
    assert!(written.iter().any(|n| n == "pre-push"));
    assert!(report.warnings.is_empty(), "fresh-create hits no conflicts");
}

// ────────────────────────────────────────────────────────────────────
// Idempotency
// ────────────────────────────────────────────────────────────────────

#[test]
fn install_is_idempotent_on_klasp_owned_hooks() {
    let dir = tempfile::tempdir().unwrap();
    let surface = CodexSurface;

    surface.install(&ctx(dir.path().to_path_buf())).unwrap();
    let after_first = read(&pre_commit(dir.path()));
    let after_first_push = read(&pre_push(dir.path()));

    let report2 = surface
        .install_detailed(&ctx(dir.path().to_path_buf()))
        .unwrap();
    let after_second = read(&pre_commit(dir.path()));
    let after_second_push = read(&pre_push(dir.path()));

    assert_eq!(after_first, after_second);
    assert_eq!(after_first_push, after_second_push);
    assert!(
        report2.report.already_installed,
        "second install should report already_installed=true"
    );
    assert!(
        report2.report.paths_written.is_empty(),
        "idempotent re-install must write nothing"
    );
}

// ────────────────────────────────────────────────────────────────────
// Existing user hook (no klasp marker, no recognised tool)
// ────────────────────────────────────────────────────────────────────

#[test]
fn install_appends_block_to_existing_user_hook() {
    let dir = tempfile::tempdir().unwrap();
    ensure_git_hooks_dir(dir.path());
    fs::write(pre_commit(dir.path()), FIXTURE_USER_BASH).unwrap();

    let surface = CodexSurface;
    let report = surface
        .install_detailed(&ctx(dir.path().to_path_buf()))
        .unwrap();

    let body = read(&pre_commit(dir.path()));
    // User content survives byte-for-byte at the head.
    assert!(body.starts_with(FIXTURE_USER_BASH.trim_end_matches('\n')));
    // Original markers / commands still present.
    assert!(body.contains("WIP-do-not-merge"));
    assert!(body.contains("set -euo pipefail"));
    // klasp block tacked on at the end.
    assert!(body.contains(MANAGED_START));
    assert!(body.contains(MANAGED_END));
    assert!(body.contains(&format!("KLASP_GATE_SCHEMA={GATE_SCHEMA_VERSION}")));
    // No warnings — this is a plain user hook, not a foreign tool's.
    assert!(report.warnings.is_empty());
}

// ────────────────────────────────────────────────────────────────────
// Conflict detection — husky / lefthook / pre-commit framework
// ────────────────────────────────────────────────────────────────────

#[test]
fn install_detects_husky_and_skips_with_warning() {
    let dir = tempfile::tempdir().unwrap();
    ensure_git_hooks_dir(dir.path());
    fs::write(pre_commit(dir.path()), FIXTURE_HUSKY).unwrap();

    let surface = CodexSurface;
    let report = surface
        .install_detailed(&ctx(dir.path().to_path_buf()))
        .unwrap();

    // Hook file is byte-for-byte unchanged.
    assert_eq!(read(&pre_commit(dir.path())), FIXTURE_HUSKY);
    // Warning surfaced for the pre-commit hook only — pre-push is still
    // missing in this fixture, so klasp creates it normally.
    let husky_warnings: Vec<_> = report
        .warnings
        .iter()
        .filter(|w| {
            matches!(
                w,
                HookWarning::Skipped {
                    conflict: HookConflict::Husky,
                    ..
                }
            )
        })
        .collect();
    assert_eq!(husky_warnings.len(), 1);
    // `Skipped` is currently the only `HookWarning` variant, so this is
    // an irrefutable destructure rather than an `if let`. Future
    // variants may turn this back into an `if let`/`match` — leaving
    // the field bindings explicit keeps the call-site stable across
    // either evolution.
    let HookWarning::Skipped { kind, path, .. } = husky_warnings[0];
    assert_eq!(*kind, HookKind::Commit);
    assert_eq!(*path, pre_commit(dir.path()));
    // pre-push, having no fixture content, is created normally.
    assert!(read(&pre_push(dir.path())).contains(MANAGED_START));
}

#[test]
fn install_detects_lefthook_and_skips_with_warning() {
    let dir = tempfile::tempdir().unwrap();
    ensure_git_hooks_dir(dir.path());
    fs::write(pre_commit(dir.path()), FIXTURE_LEFTHOOK).unwrap();

    let surface = CodexSurface;
    let report = surface
        .install_detailed(&ctx(dir.path().to_path_buf()))
        .unwrap();

    assert_eq!(read(&pre_commit(dir.path())), FIXTURE_LEFTHOOK);
    assert!(
        report.warnings.iter().any(|w| matches!(
            w,
            HookWarning::Skipped {
                conflict: HookConflict::Lefthook,
                ..
            }
        )),
        "expected a Lefthook skip warning, got {:?}",
        report.warnings,
    );
}

#[test]
fn install_detects_pre_commit_framework_and_skips_with_warning() {
    let dir = tempfile::tempdir().unwrap();
    ensure_git_hooks_dir(dir.path());
    fs::write(pre_commit(dir.path()), FIXTURE_PRECOMMIT_FRAMEWORK).unwrap();

    let surface = CodexSurface;
    let report = surface
        .install_detailed(&ctx(dir.path().to_path_buf()))
        .unwrap();

    assert_eq!(read(&pre_commit(dir.path())), FIXTURE_PRECOMMIT_FRAMEWORK);
    assert!(
        report.warnings.iter().any(|w| matches!(
            w,
            HookWarning::Skipped {
                conflict: HookConflict::PreCommit,
                ..
            }
        )),
        "expected a PreCommit skip warning, got {:?}",
        report.warnings,
    );
}

#[test]
fn install_does_not_fail_when_conflicts_are_present() {
    // Acceptance criterion: klasp returns a structured warning in the
    // install report; doesn't fail the install.
    let dir = tempfile::tempdir().unwrap();
    ensure_git_hooks_dir(dir.path());
    fs::write(pre_commit(dir.path()), FIXTURE_HUSKY).unwrap();
    fs::write(pre_push(dir.path()), FIXTURE_LEFTHOOK).unwrap();

    let surface = CodexSurface;
    let result = surface.install(&ctx(dir.path().to_path_buf()));
    assert!(
        result.is_ok(),
        "install must not fail on conflict; got {result:?}",
    );
    // Both fixtures are untouched.
    assert_eq!(read(&pre_commit(dir.path())), FIXTURE_HUSKY);
    assert_eq!(read(&pre_push(dir.path())), FIXTURE_LEFTHOOK);
}

// ────────────────────────────────────────────────────────────────────
// Uninstall paths
// ────────────────────────────────────────────────────────────────────

#[test]
fn uninstall_strips_klasp_section_preserves_user_content() {
    let dir = tempfile::tempdir().unwrap();
    ensure_git_hooks_dir(dir.path());
    fs::write(pre_commit(dir.path()), FIXTURE_USER_BASH).unwrap();

    let surface = CodexSurface;
    surface.install(&ctx(dir.path().to_path_buf())).unwrap();
    surface.uninstall(dir.path(), false).unwrap();

    let body = read(&pre_commit(dir.path()));
    assert_eq!(
        body, FIXTURE_USER_BASH,
        "uninstall did not restore the user-authored hook byte-for-byte"
    );
}

#[test]
fn uninstall_removes_file_when_klasp_was_only_content() {
    // Round-trip from the fresh-create install: install creates the
    // hook file from scratch (shebang + block); uninstall must remove
    // it again so the repo has no residual klasp shrapnel.
    let dir = tempfile::tempdir().unwrap();
    let surface = CodexSurface;
    surface.install(&ctx(dir.path().to_path_buf())).unwrap();
    assert!(pre_commit(dir.path()).exists());
    assert!(pre_push(dir.path()).exists());

    surface.uninstall(dir.path(), false).unwrap();
    assert!(
        !pre_commit(dir.path()).exists(),
        "fresh-create round-trip left a residual pre-commit file"
    );
    assert!(
        !pre_push(dir.path()).exists(),
        "fresh-create round-trip left a residual pre-push file"
    );
}

#[test]
fn uninstall_leaves_foreign_tool_hook_untouched() {
    let dir = tempfile::tempdir().unwrap();
    ensure_git_hooks_dir(dir.path());
    fs::write(pre_commit(dir.path()), FIXTURE_HUSKY).unwrap();

    let surface = CodexSurface;
    // First install to capture warnings, then uninstall.
    surface.install(&ctx(dir.path().to_path_buf())).unwrap();
    surface.uninstall(dir.path(), false).unwrap();

    // Husky hook is byte-for-byte unchanged across both install and
    // uninstall. klasp never wrote into it, so there's no managed
    // block to strip.
    assert_eq!(read(&pre_commit(dir.path())), FIXTURE_HUSKY);
}

#[test]
fn uninstall_dry_run_does_not_modify_disk() {
    let dir = tempfile::tempdir().unwrap();
    let surface = CodexSurface;
    surface.install(&ctx(dir.path().to_path_buf())).unwrap();

    let pre_install_body = read(&pre_commit(dir.path()));
    surface.uninstall(dir.path(), true).unwrap();
    let post_dry_run_body = read(&pre_commit(dir.path()));

    assert_eq!(pre_install_body, post_dry_run_body);
    assert!(pre_commit(dir.path()).exists());
}

// ────────────────────────────────────────────────────────────────────
// Module-level conflict-detection sanity checks against fixtures
// ────────────────────────────────────────────────────────────────────

#[test]
fn detect_conflict_returns_husky_for_real_husky_fixture() {
    assert_eq!(
        git_hooks::detect_conflict(FIXTURE_HUSKY),
        Some(HookConflict::Husky),
    );
}

#[test]
fn detect_conflict_returns_husky_for_husky_v9_h_shim() {
    // husky v9 shortened the shim path from `_/husky.sh` to `_/h`. Without
    // the `_/h"` substring in `detect_conflict`, klasp would silently
    // append its block to a husky-managed hook on any v9-or-newer repo.
    assert_eq!(
        git_hooks::detect_conflict(FIXTURE_HUSKY_V9),
        Some(HookConflict::Husky),
    );
}

#[test]
fn detect_conflict_does_not_false_positive_on_husky_in_user_comment() {
    // A user comment merely mentioning husky must not trip the husky
    // arm. The pre-fix detection used a bare `.husky/` substring which
    // would have false-positived this hook.
    let user_hook = "#!/usr/bin/env sh\n# Migrated from .husky/ — now managed manually\nnpm test\n";
    assert_eq!(git_hooks::detect_conflict(user_hook), None);
}

#[test]
fn detect_conflict_returns_lefthook_for_real_lefthook_fixture() {
    assert_eq!(
        git_hooks::detect_conflict(FIXTURE_LEFTHOOK),
        Some(HookConflict::Lefthook),
    );
}

#[test]
fn detect_conflict_returns_pre_commit_for_real_pre_commit_framework_fixture() {
    assert_eq!(
        git_hooks::detect_conflict(FIXTURE_PRECOMMIT_FRAMEWORK),
        Some(HookConflict::PreCommit),
    );
}

#[test]
fn detect_conflict_returns_none_for_user_hook() {
    assert_eq!(git_hooks::detect_conflict(FIXTURE_USER_BASH), None);
}

#[test]
fn detect_conflict_does_not_match_bare_h_in_user_path() {
    // Tighter than the previous `_/h"` substring: the `/` prefix anchors
    // on husky's actual dotted-source line. A user hook that happens to
    // include `_/h"` in a non-husky context (here-doc literal, comment
    // mentioning the path) must not false-positive.
    let user_hook = "#!/usr/bin/env sh\ncat <<EOF\nrelative path: _/h\"\nEOF\nnpm test\n";
    assert_eq!(git_hooks::detect_conflict(user_hook), None);
}

#[test]
fn uninstall_tolerates_mangled_klasp_markers_without_aborting_other_paths() {
    // Mid-loop failure on a single mangled hook used to fail the whole
    // uninstall and leave the repo half-cleaned (AGENTS.md modified
    // before the hook step errored out). The fixed surface logs nothing
    // and skips the bad hook; the user fixes their hook and re-runs.
    let dir = tempfile::tempdir().unwrap();
    let surface = CodexSurface;

    // Stage a clean install first so AGENTS.md + the pre-push hook
    // both contain klasp content.
    surface.install(&ctx(dir.path().to_path_buf())).unwrap();

    // Truncate the pre-commit hook to a start marker without a closing
    // end marker — the malformed-marker case `find_block` rejects.
    fs::write(
        pre_commit(dir.path()),
        format!("#!/usr/bin/env sh\n{MANAGED_START}\n# end marker missing\n"),
    )
    .unwrap();

    // Uninstall must not error: it should clean the well-formed paths
    // (AGENTS.md, pre-push) and silently leave the mangled pre-commit alone.
    let paths = surface.uninstall(dir.path(), false).unwrap();

    // pre-commit hook still exists, untouched.
    assert!(pre_commit(dir.path()).exists());
    let pc = read(&pre_commit(dir.path()));
    assert!(pc.contains(MANAGED_START), "mangled hook left intact");
    assert!(!pc.contains(MANAGED_END));

    // pre-push hook was klasp-only → removed.
    assert!(!pre_push(dir.path()).exists());

    // AGENTS.md was klasp-only → removed.
    assert!(!dir.path().join("AGENTS.md").exists());

    // The returned `paths` should reflect the well-formed targets we
    // actually touched. Mangled pre-commit is not in there.
    assert!(!paths.iter().any(|p| p == &pre_commit(dir.path())));
}