parse_git_apply_output mis-attributes paths via BTreeSet::next_back()

Open 💬 3 comments Opened Jun 29, 2026 by syedali5612

What

parse_git_apply_output in codex-rs/git-utils/src/apply.rs classifies each path from git apply into applied / skipped / conflicted BTreeSets and tracks last_seen_path. After inserting a path it recovers "the path just inserted" with <set>.iter().next_back().cloned() (6 sites: lines ~459, 471, 483, 497, 550, 564).

next_back() returns the lexicographically greatest element of the set, not the element just inserted. Once a set holds more than one path, the cross-set remove() reconciliation and last_seen_path are applied to the wrong file — so a later THREE_WAY_FAILED / LACKS_BLOB skip can be charged to the wrong path, and applied/skipped/conflicted classification gets swapped.

Repro

Input (paths chosen so the lexicographically-greatest is inserted first):

Applied patch zzz.txt cleanly.
Applied patch aaa.txt cleanly.
Failed to perform three-way merge...

Expected: applied == ["zzz.txt"], skipped == ["aaa.txt"].
Actual: the three-way-merge failure is attributed to zzz.txt (the set max), so zzz.txt is de-applied and aaa.txt is wrongly kept as applied.

Root cause

The local add(set, raw) helper inserts but returns nothing, and callers reach back into the BTreeSet with next_back() to guess what was added. BTreeSet is ordered, so that guess is the max, not the insertion.

Suggested fix (outline)

Have the add helper return the inserted (unescaped) key, and use that returned value at every call site instead of next_back(). I tried this locally: it is a minimal change, keeps clippy (collapsible_if) and cargo fmt clean, and a regression test built from the repro above goes from failing to passing (cargo test -p codex-git-utils: 23 passed). Happy to open a PR with the fix + test if you would like — flagging via an issue first per the contributing guidelines.

View original on GitHub ↗

3 Comments

wgu9 · 1 month ago

I checked current main (80f54d1) and this is still present in codex-rs/git-utils/src/apply.rs.

The important owner path is exactly the one described here: parse_git_apply_output() inserts into BTreeSets and then recovers a path with iter().next_back() at multiple sites. Since next_back() returns the lexicographically greatest path, not the path just inserted, later reconciliation can remove/update the wrong path once the set has more than one entry.

The smallest fix shape looks low-risk:

  • change local helper add(set, raw) to return Option<String> for the normalized/unescaped inserted key
  • use that returned key at each current next_back() site
  • add a regression using two applied paths where the second inserted path sorts before the first, followed by Failed to perform three-way merge...

Suggested targeted validation if this gets picked up:

cargo test -p codex-git-utils parse_git_apply_output -- --nocapture
Osamaali313 · 1 month ago

Independently reproduced and root-caused on current main (ccdfb4f), with run-verified RED→GREEN and a concrete patch below. Confirming the analysis above.

Reproduced (RED) on main

Added a regression test to codex-rs/git-utils/src/apply.rs's mod tests using the repro from the issue:

#[test]
fn parse_output_attributes_status_to_inserted_path_not_set_max() {
    let stdout = concat!(
        "Applied patch zzz.txt cleanly.\n",
        "Applied patch aaa.txt cleanly.\n",
        "Failed to perform three-way merge...\n",
    );
    let (applied, skipped, conflicted) = parse_git_apply_output(stdout, "");
    assert_eq!(conflicted, Vec::<String>::new());
    assert_eq!(applied, vec!["zzz.txt".to_string()]);
    assert_eq!(skipped, vec!["aaa.txt".to_string()]);
}

cargo test -p codex-git-utils on unmodified main:

thread '...parse_output_attributes_status_to_inserted_path_not_set_max' panicked at git-utils\src\apply.rs:
assertion `left == right` failed: three-way failure should not de-apply zzz.txt
  left: ["aaa.txt"]
 right: ["zzz.txt"]
test result: FAILED. 0 passed; 1 failed

After the two Applied patch ... cleanly. lines, applied = {aaa.txt, zzz.txt}; the add → applied.iter().next_back().cloned() recovery picks the set max (zzz.txt) as the just-touched path, so last_seen_path becomes zzz.txt. The trailing THREE_WAY_FAILED line (apply.rs:526) then charges the skip to zzz.txt — de-applying it and wrongly leaving aaa.txt applied. Buckets swapped, exactly as reported.

Fix

Make add return the key it inserted, and use that return value at all 6 call sites instead of next_back():

fn add(set: &mut std::collections::BTreeSet<String>, raw: &str) -> Option<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return None;
    }
    let first = trimmed.chars().next().unwrap_or('\0');
    let last = trimmed.chars().last().unwrap_or('\0');
    let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 {
        unescape_c_string(&trimmed[1..trimmed.len() - 1])
    } else {
        trimmed.to_string()
    };
    if unquoted.is_empty() {
        return None;
    }
    set.insert(unquoted.clone());
    Some(unquoted)
}

Each call site (lines ~459/471/483/497/550/564) collapses from add(...); let p = set.iter().next_back().cloned(); if let Some(p) = p { ... } to:

if let Some(m) = c.name("path")
    && let Some(p) = add(&mut applied, m.as_str())
{
    conflicted.remove(&p);
    skipped.remove(&p);
    last_seen_path = Some(p);
}

(let-chains keep clippy's collapsible_if happy.)

GREEN

test apply::tests::parse_output_attributes_status_to_inserted_path_not_set_max ... ok
test result: ok. 23 passed; 0 failed

cargo clippy -p codex-git-utils --lib is clean.

I have the full patch ready and would be happy to open a PR per docs/contributing.md (external contributions are by invitation) if a maintainer would like to invite one.

1837620622 · 1 month ago

Root-cause confirmation + minimal fix (fork branch ready)

Confirmed on current main in codex-rs/git-utils/src/apply.rs: after add(&mut set, path), several sites recover the inserted path with:

set.iter().next_back().cloned()

BTreeSet::iter().next_back() is the lexicographic maximum, not the just-inserted element. With ≥2 paths, last_seen_path and cross-set remove() hit the wrong file.

Minimal repro (parser unit)

Applied patch zzz.txt cleanly.
Applied patch aaa.txt cleanly.
Failed to perform three-way merge...
  • Bug: applied=["aaa.txt"], skipped=["zzz.txt"] (max path wrongly de-applied)
  • Expected: applied=["zzz.txt"], skipped=["aaa.txt"]

Proposed fix

Make local add() return Option<String> of the inserted (unquoted) path and use that return value at every former next_back() call site. No API surface change.

Validation

cargo test -p codex-git-utils --lib parse_output
# 3 passed (includes two new regressions)

Branch / compare (cannot open PR: invitation-only)

Happy to open a PR if a Codex team member invites a contribution for #30534.