newt-git 0.8.2

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
use super::*;
use std::path::Path;
use std::process::Command;

/// A `git` invocation with the developer's ambient environment removed.
///
/// Every real-git call in `lib_tests` MUST be built here. A test that inherits
/// configuration it did not ask for does not merely fail, it *misattributes*:
/// it reports a defect in whatever branch happened to be under test (#2221 —
/// `tag.gpgsign = true` turned a bare `git tag` into an annotated signed tag,
/// which needs an editor and fails headless, and three innocent recovery
/// branches were nearly written up as broken because of it).
///
/// `current_dir` alone does NOT protect you: the `GIT_DIR` family points git at
/// another repo regardless of cwd, which is exactly what happens when this
/// suite runs under a git hook.
fn git_cmd(dir: &Path) -> Command {
    let mut cmd = Command::new("git");
    cmd.current_dir(dir)
        // Ambient config: global and system are the ones a human edits.
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .env("GIT_CONFIG_NOSYSTEM", "1")
        // Identity, so scrubbing global config cannot leave `commit` with none.
        // These override config, so call sites need no `-c user.name=...`.
        .env("GIT_AUTHOR_NAME", "Tester")
        .env("GIT_AUTHOR_EMAIL", "t@example.com")
        .env("GIT_COMMITTER_NAME", "Tester")
        .env("GIT_COMMITTER_EMAIL", "t@example.com")
        // Nothing may block on a human.
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_PAGER", "cat")
        .env_remove("EDITOR")
        .env_remove("GIT_EDITOR")
        .env_remove("VISUAL")
        .env_remove("GIT_ASKPASS")
        .env_remove("SSH_ASKPASS");
    // The GIT_DIR family: inherited whenever the suite runs under a hook.
    for var in [
        "GIT_DIR",
        "GIT_WORK_TREE",
        "GIT_INDEX_FILE",
        "GIT_OBJECT_DIRECTORY",
        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
        "GIT_COMMON_DIR",
        "GIT_NAMESPACE",
        "GIT_CEILING_DIRECTORIES",
        "GIT_PREFIX",
    ] {
        cmd.env_remove(var);
    }
    cmd
}

fn git(dir: &Path, args: &[&str]) {
    let ok = git_cmd(dir)
        .args(args)
        .status()
        .expect("git runs")
        .success();
    assert!(ok, "git {args:?} failed");
}

/// A temp repo with one commit on `a.txt`.
fn repo_with_commit() -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    git(p, &["init", "-q", "-b", "main"]);
    std::fs::write(p.join("a.txt"), "hello\n").unwrap();
    git(p, &["add", "a.txt"]);
    git(p, &["commit", "-q", "-m", "first commit"]);
    dir
}

/// [`repo_with_commit`], checked out onto a non-default branch. For tests
/// that go on to commit/amend/rebase THROUGH the `git` tool or `GitEngine`
/// (not raw `git`) — those are refused on `main` by
/// `refuse_if_default_branch` (F32/#2537), and a test exercising ordinary
/// mutation isn't exercising that guard.
fn repo_with_commit_on_task_branch() -> tempfile::TempDir {
    let dir = repo_with_commit();
    git(dir.path(), &["checkout", "-q", "-b", "task"]);
    dir
}

/// A temp repo with three commits: `c1` adds `a.txt`, `c2` adds `b.txt`, `c3`
/// modifies `a.txt`. Returns the dir plus each commit's real `git rev-parse`
/// oid, oldest first, so revision/range/pathspec tests can assert against
/// real git's own idea of the history shape.
fn rev_parse(dir: &Path, rev: &str) -> String {
    String::from_utf8(
        git_cmd(dir)
            .args(["rev-parse", rev])
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap()
    .trim()
    .to_string()
}

fn repo_with_history() -> (tempfile::TempDir, Vec<String>) {
    let dir = tempfile::tempdir().unwrap();
    let p = dir.path();
    let mut ids = Vec::new();
    git(p, &["init", "-q", "-b", "main"]);
    std::fs::write(p.join("a.txt"), "hello\n").unwrap();
    git(p, &["add", "a.txt"]);
    git(p, &["commit", "-q", "-m", "c1: add a.txt"]);
    ids.push(rev_parse(p, "HEAD"));
    std::fs::write(p.join("b.txt"), "world\n").unwrap();
    git(p, &["add", "b.txt"]);
    git(p, &["commit", "-q", "-m", "c2: add b.txt"]);
    ids.push(rev_parse(p, "HEAD"));
    std::fs::write(p.join("a.txt"), "hello again\n").unwrap();
    git(p, &["add", "a.txt"]);
    git(p, &["commit", "-q", "-m", "c3: modify a.txt"]);
    ids.push(rev_parse(p, "HEAD"));
    (dir, ids)
}

use newt_core::agentic::GitTool as _;

fn tool(dir: &Path) -> LocalGitTool {
    LocalGitTool {
        root: dir.to_path_buf(),
        author: Author {
            name: "newt-agent[bot]".into(),
            email: "bot@example.com".into(),
        },
        // The canonical attribution the session would refresh from the live
        // model + resolved identity. `from_runtime` is tool-less, so this
        // is deterministic in tests (no wall clock, no subprocess).
        attribution: Some(newt_core::attribution::CommitAttribution::from_runtime(
            "qwen3:30b",
            None,
            "noreply@newt-agent.com",
        )),
        commit_succeeded: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
        contributors_consumed: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
        signer: None,
        served_model: Default::default(),
    }
}

fn commit_count(dir: &Path) -> usize {
    let out = git_cmd(dir)
        .args(["rev-list", "--count", "HEAD"])
        .output()
        .unwrap();
    String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
}

fn head_message(dir: &Path) -> String {
    let out = git_cmd(dir)
        .args(["log", "-1", "--pretty=%B"])
        .output()
        .unwrap();
    String::from_utf8_lossy(&out.stdout).to_string()
}

/// Real `git status --porcelain` — ground truth for "the working tree ==
/// HEAD" (#2485), independent of the embedded engine's own status scan.
fn git_status_porcelain(dir: &Path) -> String {
    let out = git_cmd(dir)
        .args(["status", "--porcelain"])
        .output()
        .unwrap();
    String::from_utf8_lossy(&out.stdout).to_string()
}

#[cfg(test)]
mod ambient_environment {
    use super::*;

    /// Regression for #2221: the real-git fixtures inherited the developer's
    /// git environment. With `tag.gpgsign = true` a bare `git tag <name>`
    /// becomes an annotated *signed* tag, which needs an editor and fails
    /// headless. CI never saw it (clean runner config), so the suite reported
    /// a defect in whatever branch happened to be under test — three innocent
    /// recovery branches were nearly written up as broken on exactly this.
    ///
    /// Asserted as a differential: same repo, same command, the scrub is the
    /// only variable. The unscrubbed probe is what keeps this from going
    /// vacuously green if the hazard ever stops reproducing.
    #[test]
    fn git_cmd_defeats_a_poisoned_global_config() {
        let dir = repo_with_commit();
        let p = dir.path();

        let poison = p.join("poisoned.gitconfig");
        std::fs::write(&poison, "[tag]\n\tgpgsign = true\n").unwrap();

        // The hazard is live: an unscrubbed git honours the poisoned config.
        let seen = Command::new("git")
            .current_dir(p)
            .env("GIT_CONFIG_GLOBAL", &poison)
            .args(["config", "--get", "tag.gpgsign"])
            .output()
            .expect("git runs");
        assert_eq!(
            String::from_utf8_lossy(&seen.stdout).trim(),
            "true",
            "fixture no longer reproduces #2221; this test would be vacuous"
        );

        // Unscrubbed, that config changes what `git tag` builds: it either
        // refuses for want of an editor, or writes an annotated tag object.
        // All three stdio streams are closed because there is a third outcome
        // (#2483): with no editor configured git falls back to `vi`, which
        // waits forever on an inherited terminal. Closing stdin alone is not
        // enough — vim then reads its keys from a terminal on stderr.
        let unscrubbed = Command::new("git")
            .current_dir(p)
            .env("GIT_CONFIG_GLOBAL", &poison)
            .env_remove("EDITOR")
            .env_remove("GIT_EDITOR")
            .env_remove("VISUAL")
            .args(["tag", "poisoned"])
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .expect("git runs");
        if unscrubbed.success() {
            assert_eq!(
                object_type(p, "refs/tags/poisoned"),
                "tag",
                "poisoned config should have produced an annotated tag"
            );
        }

        // The fixture helper must be immune to all of it.
        git(p, &["tag", "clean"]);
        assert_eq!(
            object_type(p, "refs/tags/clean"),
            "commit",
            "ambient config leaked into the fixture helper"
        );
    }

    /// The `GIT_DIR` family points git at another repository regardless of
    /// `current_dir`, which is what happens when this suite runs under a git
    /// hook. `git -C` does not protect you; only removing the vars does.
    #[test]
    fn git_cmd_removes_the_git_dir_family() {
        let dir = repo_with_commit();
        let envs: std::collections::HashMap<_, _> = git_cmd(dir.path())
            .get_envs()
            .map(|(k, v)| {
                (
                    k.to_string_lossy().into_owned(),
                    v.map(|v| v.to_string_lossy().into_owned()),
                )
            })
            .collect();

        for var in [
            "GIT_DIR",
            "GIT_WORK_TREE",
            "GIT_INDEX_FILE",
            "GIT_COMMON_DIR",
        ] {
            assert_eq!(
                envs.get(var),
                Some(&None),
                "{var} must be removed, not merely left inherited"
            );
        }
        assert_eq!(
            envs.get("GIT_CONFIG_GLOBAL").and_then(|v| v.as_deref()),
            Some("/dev/null"),
            "global config must be pinned away from the developer's ~/.gitconfig"
        );
    }

    fn object_type(dir: &Path, refname: &str) -> String {
        let out = git_cmd(dir)
            .args(["cat-file", "-t", refname])
            .output()
            .expect("git runs");
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    }
}

// Families beside this file. Both attributes are required: rustc needs only
// the `#[path]`, but the ratchets' shared scanner resolves a child ONLY when
// a `#[cfg(test)]` immediately precedes the `mod` (#2149).
#[cfg(test)]
#[path = "attribution.rs"]
mod attribution;
#[cfg(test)]
#[path = "branch_list.rs"]
mod branch_list;
#[cfg(test)]
#[path = "checkout_branch.rs"]
mod checkout_branch;
#[cfg(test)]
#[path = "engine_read.rs"]
mod engine_read;
#[cfg(test)]
#[path = "signing.rs"]
mod signing;

#[path = "engine_write.rs"]
mod engine_write;
#[cfg(test)]
#[path = "git_scope.rs"]
mod git_scope;
#[cfg(test)]
#[path = "log_diff_operands.rs"]
mod log_diff_operands;
#[cfg(test)]
#[path = "rebase.rs"]
mod rebase;
#[cfg(test)]
#[path = "scoped_legacy.rs"]
mod scoped_legacy;
#[cfg(test)]
#[path = "stash_and_unknown.rs"]
mod stash_and_unknown;
#[cfg(test)]
#[path = "tool_dispatch.rs"]
mod tool_dispatch;
#[cfg(test)]
#[path = "tool_surface.rs"]
mod tool_surface;
#[path = "worktree_cwd.rs"]
mod worktree_cwd;

/// #2936 review: the trailer a commit carries must name the model that served
/// the round, not the requested one captured at turn start. A router such as
/// `openrouter/auto` serves a different model. RED before: both the git tool
/// and its native commit policy finalized with the requested `qwen3:30b`.
#[test]
fn commit_trailer_credits_the_served_model() {
    // Finalizing touches no repository, so no directory is needed.
    let tool = tool(Path::new("unused"));
    assert!(tool
        .finalize_commit_message("subject")
        .contains("qwen3:30b"));

    tool.note_served_model("routed/model-x");
    let direct = tool.finalize_commit_message("subject");
    let native = tool
        .native_commit_policy()
        .unwrap()
        .finalize_message("subject")
        .unwrap();
    for message in [direct, native] {
        assert!(message.contains("routed/model-x"), "{message}");
        assert!(!message.contains("qwen3:30b"), "{message}");
    }
}

/// #2936 review round 2: a router can serve model A for an edit and model B
/// for the round that commits, in one turn. The commit credits both. RED
/// before: only the active model B (and the turn-start snapshot) appeared.
#[test]
fn commit_credits_every_served_model_that_did_work_this_turn() {
    let tool = tool(Path::new("unused"));
    tool.note_served_model("routed/a");
    tool.note_contribution("routed/a");
    tool.note_served_model("routed/b");
    let message = tool.finalize_commit_message("subject");
    assert!(message.contains("routed/a"), "{message}");
    assert!(message.contains("routed/b"), "{message}");
}

/// #2936 review round 2: a native commit snapshot freezes the served model at
/// preparation, like the rest of the policy. RED before: a later round's
/// model leaked into the commit prepared under the earlier one.
#[test]
fn a_native_commit_snapshot_freezes_the_served_model() {
    let tool = tool(Path::new("unused"));
    tool.note_served_model("routed/a");
    let snapshot = tool
        .native_commit_policy()
        .unwrap()
        .snapshot_for_commit()
        .unwrap();
    tool.note_served_model("routed/b");
    let message = snapshot.finalize_message("subject").unwrap();
    assert!(message.contains("routed/a"), "{message}");
    assert!(!message.contains("routed/b"), "{message}");
}

/// A landed commit consumes the served contributors it credited, so the next
/// commit does not credit them again.
#[test]
fn a_landed_commit_consumes_served_contributors() {
    let tool = tool(Path::new("unused"));
    tool.note_served_model("routed/b");
    tool.note_contribution("routed/a");
    tool.native_commit_policy().unwrap().committed();
    let message = tool.finalize_commit_message("subject");
    assert!(!message.contains("routed/a"), "{message}");
    assert!(message.contains("routed/b"), "{message}");
}

/// #2936 review round 3: work a model does after a native commit was prepared
/// is credited to the next commit, even when the same model's earlier work
/// was in the prepared one. RED before: consumption matched by model name and
/// erased the later work too.
#[test]
fn later_work_by_the_same_model_survives_an_earlier_commit() {
    let tool = tool(Path::new("unused"));
    tool.note_served_model("routed/b");
    tool.note_contribution("routed/a");
    let snapshot = tool
        .native_commit_policy()
        .unwrap()
        .snapshot_for_commit()
        .unwrap();
    tool.note_contribution("routed/a");
    snapshot.committed();
    let next = tool.finalize_commit_message("subject");
    assert!(next.contains("routed/a"), "{next}");
}

/// #2936 review round 4: on the embedded route, a contribution recorded
/// between rendering a commit message and consuming after the commit landed
/// is kept for the next commit. RED before: consumption used the live cutoff.
#[test]
fn embedded_consumption_uses_the_rendered_cutoff() {
    let tool = tool(Path::new("unused"));
    tool.note_served_model("routed/b");
    tool.note_contribution("routed/a");
    let _ = tool.finalize_commit_message("subject");
    tool.note_contribution("routed/c");
    tool.consume_contributors();
    let next = tool.finalize_commit_message("subject");
    assert!(next.contains("routed/c"), "{next}");
    assert!(!next.contains("routed/a"), "{next}");
}