newt-git 0.8.0

Embedded git engine for newt agents: grit-lib wrapped in the GitCaveats OCAP surface
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
486
487
488
489
490
491
492
493
494
495
496
497
498
use super::*;

// --- rebase (structured plan) ------------------------------------------
/// A repo with three linear commits c1→c2→c3 (a/b/c.txt). Returns the dir
/// and the full oids [c1, c2, c3].
fn repo_with_three() -> (tempfile::TempDir, Vec<String>) {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "agent-work"]);
    let mk = |name: &str, content: &str, msg: &str| {
        std::fs::write(p.join(name), content).unwrap();
        git(p, &["add", name]);
        git(p, &["commit", "-q", "-m", msg]);
    };
    mk("a.txt", "v1\n", "c1");
    mk("b.txt", "b\n", "c2");
    mk("c.txt", "c\n", "c3");
    let out = git_cmd(p)
        .args(["log", "--format=%H", "--reverse"])
        .output()
        .unwrap();
    let oids = String::from_utf8_lossy(&out.stdout)
        .lines()
        .map(String::from)
        .collect();
    (dir, oids)
}
#[test]
fn rebase_rewords_a_middle_commit() {
    let (dir, oids) = repo_with_three();
    let t = tool(dir.path());
    let out = t
        .dispatch(
            "rebase",
            &serde_json::json!({
                "onto": oids[0],
                "plan": [
                    {"commit": oids[1], "action": "reword", "message": "b reworded"},
                    {"commit": oids[2], "action": "pick"},
                ]
            }),
            &GitCaveats::top(),
            &newt_core::caveats::Caveats::top(),
        )
        .unwrap();
    assert!(out.starts_with("rebased onto"), "got: {out}");
    assert_eq!(commit_count(dir.path()), 3, "same number of commits");
    // History: c1, b reworded, c3.
    let log = git_cmd(dir.path())
        .args(["log", "--format=%s", "--reverse"])
        .output()
        .unwrap();
    let subjects = String::from_utf8_lossy(&log.stdout);
    assert!(subjects.contains("b reworded"), "got: {subjects}");
    assert!(
        !subjects.contains("\nc2\n"),
        "old c2 subject gone: {subjects}"
    );
    // b.txt and c.txt still present (changes preserved).
}
/// #1709 req 9: an ordinary `pick` (NOT reword/squash) — which replays the
/// original commit's message verbatim — receives canonical Newt attribution
/// too. Every newly created rebase commit is finalized through the same
/// finalizer as `commit`/`amend`. The user subject/body is preserved; the
/// Newt model trailer + Harness provenance are appended. Real-resource.
#[test]
fn rebase_pick_commit_receives_canonical_attribution() {
    let (dir, oids) = repo_with_three();
    let p = dir.path();
    let t = tool(p);
    // The picked commit (c3) was authored by "T <t@e.c>" with a bare
    // subject "c3" and NO attribution. Replay it with a plain `pick`.
    t.dispatch(
        "rebase",
        &serde_json::json!({
            "onto": oids[0],
            "plan": [
                {"commit": oids[1], "action": "pick"},
                {"commit": oids[2], "action": "pick"},
            ]
        }),
        &GitCaveats::top(),
        &newt_core::caveats::Caveats::top(),
    )
    .unwrap();
    // The HEAD commit (c3 replayed) now carries canonical attribution.
    let body = head_message(p);
    assert!(
        body.contains(" | Model: qwen3:30b | "),
        "pick commit received the live model provenance: {body}"
    );
    assert!(
        body.contains("Co-authored-by: qwen3:30b (newt-agent v"),
        "pick commit received the model Co-authored-by trailer: {body}"
    );
    // The user's original subject/body is preserved.
    let first_line = body.lines().next().unwrap_or("");
    assert_eq!(first_line, "c3", "pick preserved the user subject: {body}");
}
#[test]
fn rebase_squashes_two_commits_into_one() {
    let (dir, oids) = repo_with_three();
    let t = tool(dir.path());
    t.dispatch(
        "rebase",
        &serde_json::json!({
            "onto": oids[0],
            "plan": [
                {"commit": oids[1], "action": "pick"},
                {"commit": oids[2], "action": "squash", "message": "folded note"},
            ]
        }),
        &GitCaveats::top(),
        &newt_core::caveats::Caveats::top(),
    )
    .unwrap();
    // c1 + one squashed commit = 2.
    assert_eq!(commit_count(dir.path()), 2);
    // The squashed commit carries both messages.
    let body = head_message(dir.path());
    assert!(
        body.contains("c2") && body.contains("folded note"),
        "got: {body}"
    );
    // Both files landed in the squashed tree.
    let files = git_cmd(dir.path())
        .args(["ls-tree", "--name-only", "-r", "HEAD"])
        .output()
        .unwrap();
    let names = String::from_utf8_lossy(&files.stdout);
    assert!(
        names.contains("b.txt") && names.contains("c.txt"),
        "got: {names}"
    );
    // #2518 (RECON item 2): the trailing-squash case used to read a stale
    // `tip_tree` (only set when the loop closed a commit on a later pick,
    // never on the final close) — tree == HEAD is what closes that gap.
    let status = git_status_porcelain(dir.path());
    assert!(status.is_empty(), "worktree must be clean: {status:?}");
    let head_tree = String::from_utf8_lossy(
        &git_cmd(dir.path())
            .args(["rev-parse", "HEAD^{tree}"])
            .output()
            .unwrap()
            .stdout,
    )
    .trim()
    .to_string();
    let worktree_tree = String::from_utf8_lossy(
        &git_cmd(dir.path())
            .args(["write-tree"])
            .output()
            .unwrap()
            .stdout,
    )
    .trim()
    .to_string();
    assert_eq!(
        head_tree, worktree_tree,
        "the checked-out tree must match HEAD's tree"
    );
}
#[test]
fn rebase_drops_a_commit() {
    let (dir, oids) = repo_with_three();
    let t = tool(dir.path());
    t.dispatch(
        "rebase",
        &serde_json::json!({
            "onto": oids[0],
            "plan": [
                {"commit": oids[1], "action": "pick"},
                {"commit": oids[2], "action": "drop"},
            ]
        }),
        &GitCaveats::top(),
        &newt_core::caveats::Caveats::top(),
    )
    .unwrap();
    assert_eq!(commit_count(dir.path()), 2);
    let names = git_cmd(dir.path())
        .args(["ls-tree", "--name-only", "-r", "HEAD"])
        .output()
        .unwrap();
    let names = String::from_utf8_lossy(&names.stdout);
    assert!(
        !names.contains("c.txt"),
        "dropped commit's file gone: {names}"
    );
    // #2485 (RECON item 2): a ref-moving op leaves tree == HEAD, or refuses.
    // After dropping c3, the ref moved but the file its commit added must
    // NOT still sit in the working tree as an unstaged leftover.
    let status = git_status_porcelain(dir.path());
    assert!(status.is_empty(), "worktree must be clean: {status:?}");
    assert!(
        !dir.path().join("c.txt").exists(),
        "c.txt must be gone from the working tree, not just from HEAD's tree"
    );
}
/// #2485 (RECON item 2): rebase refuses on a dirty tree rather than moving
/// the ref out from under uncommitted changes.
#[test]
fn rebase_refuses_on_a_dirty_tree() {
    let (dir, oids) = repo_with_three();
    let p = dir.path();
    // Dirty the tree: an unstaged edit to a tracked file.
    std::fs::write(p.join("a.txt"), "dirty\n").unwrap();
    let t = tool(p);
    let err = t
        .dispatch(
            "rebase",
            &serde_json::json!({
                "onto": oids[0],
                "plan": [
                    {"commit": oids[1], "action": "pick"},
                    {"commit": oids[2], "action": "drop"},
                ]
            }),
            &GitCaveats::top(),
            &newt_core::caveats::Caveats::top(),
        )
        .unwrap_err();
    assert!(
        err.contains("clean working tree"),
        "refusal must name the reason: {err}"
    );
    // #2518 review Q2/Q3: the refusal names which condition bit tripped it —
    // one unstaged edit, nothing staged or untracked.
    assert!(
        err.contains("0 staged, 1 unstaged, 0 untracked"),
        "refusal must name the condition counts: {err}"
    );
    // No side effects: HEAD unchanged, dirty edit untouched.
    assert_eq!(commit_count(p), 3, "refusal must not move the ref");
    assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "dirty\n");
}
/// #2517 review Q1: `checkout_between_trees`'s contract assumes "no untracked
/// file sits where an added path needs to land" — an *ignored* file (e.g.
/// `x.env`) is invisible to `status`'s clean check (which uses
/// `IgnoredMode::No`), so a rebase whose new tree ADDS that same path would
/// silently overwrite it. Must refuse instead, leaving the ref unmoved and
/// the local file untouched — real git's "untracked working tree files
/// would be overwritten" behavior.
#[test]
fn rebase_refuses_when_the_new_tree_would_overwrite_an_ignored_file() {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "agent-work"]);
    std::fs::write(p.join(".gitignore"), "x.env\n").unwrap();
    std::fs::write(p.join("a.txt"), "v1\n").unwrap();
    git(p, &["add", ".gitignore", "a.txt"]);
    git(p, &["commit", "-q", "-m", "c1"]);
    let onto = String::from_utf8_lossy(
        &git_cmd(p)
            .args(["rev-parse", "HEAD"])
            .output()
            .unwrap()
            .stdout,
    )
    .trim()
    .to_string();
    std::fs::write(p.join("x.env"), "TRACKED\n").unwrap();
    git(p, &["add", "-f", "x.env"]);
    git(p, &["commit", "-q", "-m", "c2 adds x.env"]);
    let c2 = String::from_utf8_lossy(
        &git_cmd(p)
            .args(["rev-parse", "HEAD"])
            .output()
            .unwrap()
            .stdout,
    )
    .trim()
    .to_string();
    git(p, &["rm", "-q", "x.env"]);
    git(p, &["commit", "-q", "-m", "c3 removes x.env"]);
    // HEAD (c3) has no x.env; a local, ignored x.env now sits on disk —
    // invisible to `status` and NOT what c2 tracked.
    std::fs::write(p.join("x.env"), "LOCAL\n").unwrap();
    let head_before = String::from_utf8_lossy(
        &git_cmd(p)
            .args(["rev-parse", "HEAD"])
            .output()
            .unwrap()
            .stdout,
    )
    .trim()
    .to_string();

    let t = tool(p);
    let err = t
        .dispatch(
            "rebase",
            &serde_json::json!({
                "onto": onto,
                "plan": [
                    {"commit": c2, "action": "pick"},
                ]
            }),
            &GitCaveats::top(),
            &newt_core::caveats::Caveats::top(),
        )
        .unwrap_err();
    assert!(
        err.contains("x.env"),
        "refusal must name the path it would overwrite: {err}"
    );
    // No side effects: ref unmoved, local file untouched.
    let head_after = String::from_utf8_lossy(
        &git_cmd(p)
            .args(["rev-parse", "HEAD"])
            .output()
            .unwrap()
            .stdout,
    )
    .trim()
    .to_string();
    assert_eq!(head_before, head_after, "refusal must not move the ref");
    assert_eq!(
        std::fs::read_to_string(p.join("x.env")).unwrap(),
        "LOCAL\n",
        "the local ignored file must be untouched"
    );
}
/// #1709 family: a rebase that produced ZERO commits (an all-drop plan) is
/// a successful history operation but NOT an attribution epoch. It must
/// NOT signal `commit_succeeded` and must NOT consume the contributor
/// snapshot — pending contributors survive it and a later commit in the
/// same lifecycle still credits them. Real git (tempdir + real commits)
/// because "the contributor survived onto the next commit" is a property
/// of the real commit object, not a mock.
#[test]
fn rebase_all_drop_preserves_pending_contributors() {
    let (dir, oids) = repo_with_three();
    let mut t = tool(dir.path());
    // Inject one accumulated contributor (model-a) into the envelope.
    if let Some(a) = t.attribution.as_mut() {
        a.contributors.push(
            newt_core::attribution::Attribution::new(
                "model-a",
                "newt-agent",
                newt_core::build_info::PACKAGE_VERSION,
                "noreply@newt-agent.com",
            )
            // Mirrors production: the session ledger stamps this build on
            // every contribution it records.
            .with_build(newt_core::build_info::SOURCE_ID),
        );
    }
    // All-drop plan: onto c1, drop c2 and c3 → produced == 0, dropped == 2.
    let out = t
        .dispatch(
            "rebase",
            &serde_json::json!({
                "onto": oids[0],
                "plan": [
                    {"commit": oids[1], "action": "drop"},
                    {"commit": oids[2], "action": "drop"},
                ]
            }),
            &GitCaveats::top(),
            &newt_core::caveats::Caveats::top(),
        )
        .unwrap();
    assert!(
        out.contains("0 commit(s), 2 dropped"),
        "all-drop rebase produced 0 commits: {out}"
    );
    // No Newt commit landed → no commit_succeeded signal.
    assert_eq!(
        t.drain_commit_success(),
        0,
        "a 0-produced rebase must NOT report commit_succeeded"
    );
    // The contributor cursor was NOT advanced: a subsequent commit in the
    // same lifecycle still credits model-a (the snapshot remains pending).
    std::fs::write(dir.path().join("d.txt"), "z\n").unwrap();
    t.dispatch(
        "add",
        &serde_json::json!({"paths": ["d.txt"]}),
        &GitCaveats::top(),
        &newt_core::caveats::Caveats::top(),
    )
    .unwrap();
    t.dispatch(
        "commit",
        &serde_json::json!({"message": "after all-drop rebase"}),
        &GitCaveats::top(),
        &newt_core::caveats::Caveats::top(),
    )
    .unwrap();
    let msg = head_message(dir.path());
    let version = newt_core::build_info::PACKAGE_VERSION;
    // The build revision is part of the contributor identity, so it
    // renders in the qualifier alongside the version.
    let build = newt_core::build_info::SOURCE_ID;
    assert!(
        msg.contains(&format!(
            "Co-authored-by: model-a (newt-agent v{version} {build}) <noreply@newt-agent.com>"
        )),
        "the pending contributor survived the 0-produced rebase and is credited on the next commit: {msg}"
    );
    // That next commit IS an epoch (it produced a commit) → it signals.
    assert_eq!(
        t.drain_commit_success(),
        1,
        "the commit after the all-drop rebase signals normally"
    );
}
#[test]
fn rebase_aborts_on_conflict_leaving_the_branch_unchanged() {
    // c1: a=v1; c2: a=v2; c3: a=v3. Cherry-picking c3 onto c1 conflicts
    // (both c1 and c3 changed a.txt from c2's base).
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "agent-work"]);
    let mk = |content: &str, msg: &str| {
        std::fs::write(p.join("a.txt"), content).unwrap();
        git(p, &["add", "a.txt"]);
        git(p, &["commit", "-q", "-m", msg]);
    };
    mk("v1\n", "c1");
    mk("v2\n", "c2");
    mk("v3\n", "c3");
    let head_before = git_cmd(p).args(["rev-parse", "HEAD"]).output().unwrap();
    let oids: Vec<String> = String::from_utf8_lossy(
        &git_cmd(p)
            .args(["log", "--format=%H", "--reverse"])
            .output()
            .unwrap()
            .stdout,
    )
    .lines()
    .map(String::from)
    .collect();
    let t = tool(p);
    let err = t
        .dispatch(
            "rebase",
            &serde_json::json!({
                "onto": oids[0],
                "plan": [{"commit": oids[2], "action": "pick"}]
            }),
            &GitCaveats::top(),
            &newt_core::caveats::Caveats::top(),
        )
        .unwrap_err();
    assert!(
        err.contains("conflict") && err.contains("aborted"),
        "got: {err}"
    );
    // The branch ref did NOT move.
    let head_after = git_cmd(p).args(["rev-parse", "HEAD"]).output().unwrap();
    assert_eq!(
        head_before.stdout, head_after.stdout,
        "branch must be unchanged"
    );
}
#[test]
fn rebase_denied_on_read_only() {
    let (dir, oids) = repo_with_three();
    let t = tool(dir.path());
    let err = t
        .dispatch(
            "rebase",
            &serde_json::json!({"onto": oids[0], "plan": [{"commit": oids[1], "action": "pick"}]}),
            &GitCaveats::read_only(),
            &newt_core::caveats::Caveats::top(),
        )
        .unwrap_err();
    assert!(
        err.contains("denied") && err.contains("commit"),
        "got: {err}"
    );
}
#[test]
fn local_git_tool_unknown_op_and_missing_args_error() {
    let dir = repo_with_commit();
    let t = tool(dir.path());
    let err = t
        .dispatch(
            "frobnicate",
            &serde_json::json!({}),
            &GitCaveats::top(),
            &newt_core::caveats::Caveats::top(),
        )
        .unwrap_err();
    assert!(err.contains("unknown git op"), "got: {err}");
    // commit without a message is a clear arg error, not a panic.
    let err = t
        .dispatch(
            "commit",
            &serde_json::json!({}),
            &GitCaveats::top(),
            &newt_core::caveats::Caveats::top(),
        )
        .unwrap_err();
    assert!(err.contains("message"), "got: {err}");
}