Build git_lost_work: recon the reflog in one receipted call

1336ecbf6a8a · AtlantisPleb · · parent ff63313ff305

Build git_lost_work: recon the reflog in one receipted call

The first measured plugin (OpenAgentsInc/openagents#37): a Rust guest
on the PDK that scans a mounted .git directory for unreachable commits
and stash entries — HEAD, loose refs, packed-refs, reflogs, and loose
objects read directly, no git binary, no packfile parsing. Output is
deterministic and bounded (50 lost-commit candidates by default,
capped at 100), packed objects are marked rather than decoded, and a
Host trait keeps the parser under native cargo test (13 tests) while
the shipped artifact is the digest-pinned wasm.

Built by Devin children through the openagents coder's delegate tool;
reviewed, digest-reproduced, and tested before landing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified bench/README.md
  • modified plugins/Cargo.lock
  • modified plugins/Cargo.toml
  • added plugins/git-lost-work/Cargo.toml
  • added plugins/git-lost-work/git_lost_work.wasm
  • added plugins/git-lost-work/manifest.json
  • added plugins/git-lost-work/src/lib.rs
  • added plugins/git-lost-work/src/tests.rs

Diff

8 files changed, +917 -1

bench/README.md modified +22

@@ -39,3 +39,25 @@ agent phase completes. Enable Docker Desktop's Rosetta emulation

39 39
(Settings → General → "Use Rosetta for x86_64/amd64 emulation"), use a
40 40
cloud environment (`--env daytona` and peers), or run on amd64 hardware
41 41
for scored runs. The agent phase itself runs fine under qemu.
42
43
## Suite runner
44
45
`bench/run-suite.sh` runs a suite file through Harbor and posts the result.
46
47
```sh
48
bench/run-suite.sh <suite-file> --model <harbor-model> [options]
49
```
50
51
Examples:
52
53
```sh
54
bench/run-suite.sh bench/suites/tb2-cross-section.txt \
55
  --model openai/gpt-5.6-luna --lane proxy --n-concurrent 2
56
57
bench/run-suite.sh bench/suites/tb2-cross-section.txt \
58
  --model ollama/qwen3.8:27b-mtp-q8_0 --lane local --dry-run
59
```
60
61
Options include `--lane`, `--api-url`, `--jobs-dir`, `--n-concurrent`,
62
`--timeout-multiplier`, and `--dry-run`. Set `OPENAGENTS_TOKEN` unless the
63
model starts with `ollama/`.
plugins/Cargo.lock modified +25

@@ -2,6 +2,12 @@

2 2
# It is not intended for manual editing.
3 3
version = 4
4 4
5
[[package]]
6
name = "adler2"
7
version = "2.0.1"
8
source = "registry+https://github.com/rust-lang/crates.io-index"
9
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
5 11
[[package]]
6 12
name = "dir-stats"
7 13
version = "0.1.0"

@@ -27,6 +33,16 @@ dependencies = [

27 33
 "serde_json",
28 34
]
29 35
36
[[package]]
37
name = "git-lost-work"
38
version = "0.1.0"
39
dependencies = [
40
 "miniz_oxide",
41
 "openagents-pdk",
42
 "serde",
43
 "serde_json",
44
]
45
30 46
[[package]]
31 47
name = "itoa"
32 48
version = "1.0.18"

@@ -39,6 +55,15 @@ version = "2.8.3"

39 55
source = "registry+https://github.com/rust-lang/crates.io-index"
40 56
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
41 57
58
[[package]]
59
name = "miniz_oxide"
60
version = "0.8.9"
61
source = "registry+https://github.com/rust-lang/crates.io-index"
62
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
63
dependencies = [
64
 "adler2",
65
]
66
42 67
[[package]]
43 68
name = "openagents-pdk"
44 69
version = "0.1.0"
plugins/Cargo.toml modified +1 -1

@@ -13,7 +13,7 @@

13 13
14 14
[workspace]
15 15
resolver = "2"
16
members = ["pdk", "word-stats", "file-stats", "dir-stats", "foreign-sessions"]
16
members = ["pdk", "word-stats", "file-stats", "dir-stats", "foreign-sessions", "git-lost-work"]
17 17
18 18
[workspace.package]
19 19
edition = "2021"
plugins/git-lost-work/Cargo.toml added +15

@@ -0,0 +1,15 @@

1
[package]
2
name = "git-lost-work"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin that scans a mounted .git directory for unreachable commits and stash entries without a git binary."
7
8
[lib]
9
crate-type = ["cdylib", "rlib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
14
serde_json = { workspace = true }
15
miniz_oxide = { version = "0.8", default-features = false, features = ["with-alloc"] }
plugins/git-lost-work/git_lost_work.wasm added

Binary file. Nothing to show as text.

plugins/git-lost-work/manifest.json added +98

@@ -0,0 +1,98 @@

1
{
2
  "manifest_version": 1,
3
  "name": "git_lost_work",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Scan a mounted .git directory for unreachable commits and stash entries. It reads HEAD, loose refs, packed-refs, reflogs, and loose objects directly. It does not shell out to git, write anything, or parse packfiles. Use it when asked what commits or stashes may have been lost from a local git repository.",
7
  "artifact": {
8
    "path": "git_lost_work.wasm",
9
    "digest": "sha256:366760578cb1d83ab49a0819150308014401972907a44c89618e74de3a906c36"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "max_lost_commits": {
21
          "type": "integer",
22
          "description": "Maximum number of lost commits to report. Default 50, capped at 100."
23
        }
24
      },
25
      "required": [],
26
      "additionalProperties": false
27
    },
28
    "output": {
29
      "type": "object",
30
      "properties": {
31
        "ok": {
32
          "type": "object",
33
          "properties": {
34
            "head": {
35
              "type": "object",
36
              "properties": {
37
                "branch": { "type": ["string", "null"] },
38
                "sha": { "type": ["string", "null"] }
39
              }
40
            },
41
            "stash_entries": {
42
              "type": "array",
43
              "items": {
44
                "type": "object",
45
                "properties": {
46
                  "selector": { "type": "string" },
47
                  "sha": { "type": "string" },
48
                  "message": { "type": ["string", "null"] },
49
                  "timestamp": { "type": "integer" }
50
                }
51
              }
52
            },
53
            "lost_commits": {
54
              "type": "array",
55
              "items": {
56
                "type": "object",
57
                "properties": {
58
                  "sha": { "type": "string" },
59
                  "action": { "type": ["string", "null"] },
60
                  "timestamp": { "type": "integer" },
61
                  "packed": { "type": "boolean" },
62
                  "subject": { "type": ["string", "null"] },
63
                  "author": { "type": ["string", "null"] },
64
                  "author_date": { "type": ["integer", "null"] }
65
                }
66
              }
67
            },
68
            "summary": {
69
              "type": "object",
70
              "properties": {
71
                "total_lost_candidates": { "type": "integer" },
72
                "stashes_count": { "type": "integer" }
73
              }
74
            }
75
          }
76
        },
77
        "refusal": {
78
          "type": "object",
79
          "properties": {
80
            "code": { "type": "string" },
81
            "reason": { "type": "string" }
82
          },
83
          "required": ["code", "reason"]
84
        }
85
      }
86
    }
87
  },
88
  "capabilities": {
89
    "mounts": [
90
      { "path": ".", "readonly": true }
91
    ],
92
    "hosts": [],
93
    "timeout_ms": 10000,
94
    "memory_max_mib": 128
95
  },
96
  "price_msats": null,
97
  "license": "Apache-2.0"
98
}
plugins/git-lost-work/src/lib.rs added +498

@@ -0,0 +1,498 @@

1
//! Guest plugin that scans a mounted `.git` directory for unreachable
2
//! commits and stash entries.
3
//!
4
//! It reads Git state directly: `HEAD`, loose refs, `packed-refs`, reflogs,
5
//! and loose objects. It does not shell out, does not call a `git` binary,
6
//! and does not parse packfiles. Output is a deterministic JSON value with
7
//! the current branch or detached HEAD, stash entries, and a bounded list
8
//! of lost commit candidates.
9
10
use openagents_pdk::{
11
    list_mounted_dir, plugin_entry, read_mounted_file, MountDirListing, Refusal,
12
};
13
use serde::{Deserialize, Serialize};
14
use std::collections::{HashMap, HashSet};
15
16
const DEFAULT_MAX_LOST_COMMITS: usize = 50;
17
const MAX_LOST_COMMITS_CAP: usize = 100;
18
const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
19
const HEAD_REF_PREFIX: &str = "ref: refs/heads/";
20
const REFS_DIR: &str = ".git/refs/heads";
21
const HEAD_PATH: &str = ".git/HEAD";
22
const PACKED_REFS_PATH: &str = ".git/packed-refs";
23
const HEAD_LOG_PATH: &str = ".git/logs/HEAD";
24
const HEADS_LOG_DIR: &str = ".git/logs/refs/heads";
25
const STASH_LOG_PATH: &str = ".git/logs/refs/stash";
26
const STASH_REF_PATH: &str = ".git/refs/stash";
27
const OBJECTS_DIR: &str = ".git/objects";
28
const GIT_DIR_ENTRY: &str = ".git";
29
30
#[derive(Deserialize)]
31
pub struct Input {
32
    /// Maximum number of lost commits to report. Default 50, capped at 100.
33
    #[serde(default)]
34
    pub max_lost_commits: Option<usize>,
35
}
36
37
#[derive(Debug, Default, Serialize, PartialEq, Eq)]
38
pub struct Head {
39
    /// The current branch name, or `null` when HEAD is detached or unknown.
40
    pub branch: Option<String>,
41
    /// The resolved SHA of HEAD, or `null` when it cannot be read.
42
    pub sha: Option<String>,
43
}
44
45
#[derive(Debug, Serialize, PartialEq, Eq)]
46
pub struct StashEntry {
47
    pub selector: String,
48
    pub sha: String,
49
    #[serde(skip_serializing_if = "Option::is_none")]
50
    pub message: Option<String>,
51
    pub timestamp: i64,
52
}
53
54
#[derive(Debug, Serialize, PartialEq, Eq)]
55
pub struct LostCommit {
56
    pub sha: String,
57
    #[serde(skip_serializing_if = "Option::is_none")]
58
    pub action: Option<String>,
59
    pub timestamp: i64,
60
    pub packed: bool,
61
    #[serde(skip_serializing_if = "Option::is_none")]
62
    pub subject: Option<String>,
63
    #[serde(skip_serializing_if = "Option::is_none")]
64
    pub author: Option<String>,
65
    #[serde(skip_serializing_if = "Option::is_none")]
66
    pub author_date: Option<i64>,
67
}
68
69
#[derive(Debug, Default, Serialize, PartialEq, Eq)]
70
pub struct Summary {
71
    pub total_lost_candidates: usize,
72
    pub stashes_count: usize,
73
}
74
75
#[derive(Debug, Default, Serialize, PartialEq, Eq)]
76
pub struct Output {
77
    pub head: Head,
78
    pub stash_entries: Vec<StashEntry>,
79
    pub lost_commits: Vec<LostCommit>,
80
    pub summary: Summary,
81
}
82
83
/// The two host capabilities the scanner uses, abstracted so the core logic
84
/// runs under `cargo test` with a fake host and inside the WASM host.
85
pub trait Host {
86
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal>;
87
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal>;
88
}
89
90
struct RealHost;
91
92
impl Host for RealHost {
93
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
94
        list_mounted_dir(mount_index, path)
95
    }
96
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
97
        read_mounted_file(path)
98
    }
99
}
100
101
/// A one-line helper so failures are not thrown until the call site.
102
fn read_string(host: &dyn Host, path: &str) -> Option<String> {
103
    host.read(path).ok().map(|b| String::from_utf8_lossy(&b).into_owned())
104
}
105
106
/// True when the text is 40 hexadecimal characters.
107
fn is_sha40(text: &str) -> bool {
108
    text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit())
109
}
110
111
/// Trim newlines and trailing whitespace from Git ref contents.
112
fn trim_ref(text: &str) -> &str {
113
    text.trim_end_matches(|c: char| c == '\n' || c == '\r' || c == ' ' || c == '\t')
114
}
115
116
/// Resolve `HEAD` into a branch name and/or a SHA.
117
fn read_head(host: &dyn Host) -> Head {
118
    let text = match read_string(host, HEAD_PATH) {
119
        Some(t) => t,
120
        None => return Head::default(),
121
    };
122
    let first = text.lines().next().unwrap_or("").trim();
123
    if first.starts_with(HEAD_REF_PREFIX) {
124
        let branch = first[HEAD_REF_PREFIX.len()..].trim().to_string();
125
        let ref_path = format!(".git/refs/heads/{branch}");
126
        let sha = read_string(host, &ref_path)
127
            .as_deref()
128
            .map(trim_ref)
129
            .filter(|s| is_sha40(s))
130
            .map(str::to_string);
131
        Head {
132
            branch: Some(branch),
133
            sha,
134
        }
135
    } else {
136
        let sha = trim_ref(first);
137
        if is_sha40(sha) {
138
            Head {
139
                branch: None,
140
                sha: Some(sha.to_string()),
141
            }
142
        } else {
143
            Head::default()
144
        }
145
    }
146
}
147
148
/// Recursively list a directory and collect relative paths to all files
149
/// under it. Uses the host's confined listing import; failures stop descent.
150
fn collect_files_recursive(
151
    host: &dyn Host,
152
    mount_index: u32,
153
    path: &str,
154
    out: &mut Vec<String>,
155
) {
156
    let listing = match host.list(mount_index, path) {
157
        Ok(l) => l,
158
        Err(_) => return,
159
    };
160
    for entry in listing.entries {
161
        let child = if path.is_empty() {
162
            entry.name
163
        } else {
164
            format!("{path}/{}", entry.name)
165
        };
166
        match entry.kind.as_str() {
167
            "file" => out.push(child),
168
            "dir" => collect_files_recursive(host, mount_index, &child, out),
169
            _ => {}
170
        }
171
    }
172
}
173
174
/// Read every loose ref under `.git/refs/heads/` and return `(refname, sha)`.
175
fn read_loose_refs(host: &dyn Host) -> Vec<(String, String)> {
176
    let mut files = Vec::new();
177
    collect_files_recursive(host, 0, REFS_DIR, &mut files);
178
    let mut out = Vec::new();
179
    for file in files {
180
        let name = file.strip_prefix(".git/refs/heads/").unwrap_or(&file).to_string();
181
        let sha = match read_string(host, &file) {
182
            Some(text) => trim_ref(&text).to_string(),
183
            None => continue,
184
        };
185
        if is_sha40(&sha) {
186
            out.push((name, sha));
187
        }
188
    }
189
    out
190
}
191
192
/// Parse `packed-refs` for current ref tips. Skip comments and `^` peeled lines.
193
fn read_packed_refs(host: &dyn Host) -> Vec<(String, String)> {
194
    let text = match read_string(host, PACKED_REFS_PATH) {
195
        Some(t) => t,
196
        None => return Vec::new(),
197
    };
198
    let mut out = Vec::new();
199
    for line in text.lines() {
200
        let line = line.trim();
201
        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
202
            continue;
203
        }
204
        let mut parts = line.split_whitespace();
205
        let sha = parts.next().unwrap_or("");
206
        let refname = parts.next().unwrap_or("");
207
        if is_sha40(sha) && !refname.is_empty() {
208
            out.push((refname.to_string(), sha.to_string()));
209
        }
210
    }
211
    out
212
}
213
214
/// One raw reflog entry after parsing.
215
#[derive(Debug, Clone)]
216
struct ReflogEntry {
217
    old: String,
218
    new: String,
219
    timestamp: i64,
220
    message: String,
221
}
222
223
/// Parse a single reflog line into old/new SHA, timestamp, and message.
224
fn parse_reflog_line(line: &str) -> Option<ReflogEntry> {
225
    if line.is_empty() || line.starts_with('#') {
226
        return None;
227
    }
228
    let (prefix, message) = match line.find('\t') {
229
        Some(idx) => (&line[..idx], &line[idx + 1..]),
230
        None => (line, ""),
231
    };
232
    let prefix = prefix.trim_end();
233
    let tokens: Vec<&str> = prefix.split_whitespace().collect();
234
    if tokens.len() < 6 {
235
        return None;
236
    }
237
    let old = tokens.first()?;
238
    let new = tokens.get(1)?;
239
    if !is_sha40(old) || !is_sha40(new) {
240
        return None;
241
    }
242
    let tz = tokens.last()?;
243
    if !tz.starts_with('+') && !tz.starts_with('-') {
244
        return None;
245
    }
246
    let time_token = tokens.get(tokens.len() - 2)?;
247
    let timestamp = time_token.parse::<i64>().ok()?;
248
    Some(ReflogEntry {
249
        old: old.to_string(),
250
        new: new.to_string(),
251
        timestamp,
252
        message: message.trim_end_matches(|c: char| c == '\n' || c == '\r').to_string(),
253
    })
254
}
255
256
/// Read a reflog file and parse every line that matches the expected shape.
257
fn read_reflog(host: &dyn Host, path: &str) -> Vec<ReflogEntry> {
258
    let text = match read_string(host, path) {
259
        Some(t) => t,
260
        None => return Vec::new(),
261
    };
262
    text.lines().filter_map(parse_reflog_line).collect()
263
}
264
265
/// List the `.git/logs/refs/heads/` directory and read each branch reflog.
266
fn read_branch_reflogs(host: &dyn Host) -> Vec<ReflogEntry> {
267
    let listing = match host.list(0, HEADS_LOG_DIR) {
268
        Ok(l) => l,
269
        Err(_) => return Vec::new(),
270
    };
271
    let mut out = Vec::new();
272
    for entry in listing.entries {
273
        if entry.kind != "file" {
274
            continue;
275
        }
276
        let path = format!("{HEADS_LOG_DIR}/{}", entry.name);
277
        out.extend(read_reflog(host, &path));
278
    }
279
    out
280
}
281
282
/// Stash entries from `logs/refs/stash`, falling back to `refs/stash`.
283
fn read_stash(host: &dyn Host) -> Vec<StashEntry> {
284
    let mut out = Vec::new();
285
    let mut index = 0i64;
286
    for entry in read_reflog(host, STASH_LOG_PATH) {
287
        out.push(StashEntry {
288
            selector: format!("stash@{{{index}}}"),
289
            sha: entry.new.clone(),
290
            message: if entry.message.is_empty() { None } else { Some(entry.message) },
291
            timestamp: entry.timestamp,
292
        });
293
        index += 1;
294
    }
295
    if out.is_empty() {
296
        if let Some(text) = read_string(host, STASH_REF_PATH) {
297
            let sha = trim_ref(&text).to_string();
298
            if is_sha40(&sha) {
299
                out.push(StashEntry {
300
                    selector: "stash@{0}".to_string(),
301
                    sha,
302
                    message: None,
303
                    timestamp: 0,
304
                });
305
            }
306
        }
307
    }
308
    out
309
}
310
311
/// Read a loose object from `.git/objects/<first-2-hex>/<remaining-38-hex>`.
312
fn read_loose_object(host: &dyn Host, sha: &str) -> Option<Vec<u8>> {
313
    let prefix = &sha[..2];
314
    let suffix = &sha[2..];
315
    let path = format!("{OBJECTS_DIR}/{prefix}/{suffix}");
316
    host.read(&path).ok()
317
}
318
319
/// Decompress and parse a Git commit object. Returns `(subject, author, author_date)`.
320
fn parse_commit_object(bytes: &[u8]) -> Option<(String, String, i64)> {
321
    let inflated = miniz_oxide::inflate::decompress_to_vec_zlib(bytes).ok()?;
322
    let text = String::from_utf8_lossy(&inflated);
323
    let null_pos = text.find('\0')?;
324
    let header = &text[..null_pos];
325
    if !header.starts_with("commit ") {
326
        return None;
327
    }
328
    let body = &text[null_pos + 1..];
329
    let mut author: Option<String> = None;
330
    let mut author_date: Option<i64> = None;
331
    let mut message_start: Option<usize> = None;
332
    for (i, line) in body.lines().enumerate() {
333
        if line.starts_with("author ") {
334
            if let Some(rest) = line.strip_prefix("author ") {
335
                author = Some(parse_author_name(rest));
336
                author_date = parse_author_date(rest);
337
            }
338
        }
339
        if line.is_empty() {
340
            message_start = Some(body.lines().take(i + 1).map(|l| l.len() + 1).sum::<usize>());
341
            break;
342
        }
343
    }
344
    let message = if let Some(start) = message_start {
345
        body.get(start..).unwrap_or("").to_string()
346
    } else {
347
        body.to_string()
348
    };
349
    let subject = message.trim().lines().next().unwrap_or("").to_string();
350
    Some((subject, author.unwrap_or_default(), author_date.unwrap_or(0)))
351
}
352
353
/// Extract the human-readable author name and email from an `author` line.
354
fn parse_author_name(line: &str) -> String {
355
    let email_end = match line.rfind('>') {
356
        Some(idx) => idx,
357
        None => return line.to_string(),
358
    };
359
    let before = &line[..=email_end];
360
    before.to_string()
361
}
362
363
/// Extract the integer timestamp from an `author` line.
364
fn parse_author_date(line: &str) -> Option<i64> {
365
    let email_end = line.rfind('>')?;
366
    let tail = &line[email_end + 1..];
367
    let mut parts = tail.split_whitespace();
368
    parts.next()?.parse::<i64>().ok()
369
}
370
371
/// Determine whether the `.git` directory exists on the mount.
372
fn git_dir_present(host: &dyn Host) -> bool {
373
    host.list(0, ".").ok().map_or(false, |l| {
374
        l.entries.iter().any(|e| e.name == GIT_DIR_ENTRY && e.kind == "dir")
375
    })
376
}
377
378
/// The whole scan, over any [`Host`]. Fail-soft: a missing `.git` or file
379
/// contributes an empty or partial result rather than a refusal.
380
pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
381
    if !git_dir_present(host) {
382
        return Ok(Output::default());
383
    }
384
385
    let head = read_head(host);
386
    let max = input
387
        .max_lost_commits
388
        .unwrap_or(DEFAULT_MAX_LOST_COMMITS)
389
        .clamp(1, MAX_LOST_COMMITS_CAP);
390
391
    // Current ref tips: these SHAs are reachable and NOT lost.
392
    let mut tips: HashSet<String> = HashSet::new();
393
    if let Some(ref sha) = head.sha {
394
        tips.insert(sha.clone());
395
    }
396
    for (_, sha) in read_loose_refs(host) {
397
        tips.insert(sha);
398
    }
399
    for (refname, sha) in read_packed_refs(host) {
400
        tips.insert(sha);
401
        // A detached HEAD may be recorded as `HEAD` in packed-refs.
402
        if refname == "HEAD" && head.sha.is_none() {
403
            // already covered by tip set, no branch.
404
        }
405
    }
406
407
    // Collect reflog candidates and remember the latest message/timestamp.
408
    let mut candidate_actions: HashMap<String, (i64, String)> = HashMap::new();
409
    for entry in read_reflog(host, HEAD_LOG_PATH).into_iter().chain(read_branch_reflogs(host)) {
410
        let timestamp = entry.timestamp;
411
        let message = &entry.message;
412
        for sha in [entry.old, entry.new] {
413
            if sha == ZERO_SHA {
414
                continue;
415
            }
416
            update_candidate(&mut candidate_actions, sha, timestamp, message);
417
        }
418
    }
419
420
    let stash = read_stash(host);
421
    let mut lost: Vec<LostCommit> = Vec::new();
422
    for (sha, (timestamp, action)) in candidate_actions {
423
        if tips.contains(&sha) {
424
            continue;
425
        }
426
        if let Some(bytes) = read_loose_object(host, &sha) {
427
            if let Some((subject, author, author_date)) = parse_commit_object(&bytes) {
428
                lost.push(LostCommit {
429
                    sha,
430
                    action: if action.is_empty() { None } else { Some(action) },
431
                    timestamp,
432
                    packed: false,
433
                    subject: if subject.is_empty() { None } else { Some(subject) },
434
                    author: if author.is_empty() { None } else { Some(author) },
435
                    author_date: if author_date == 0 { None } else { Some(author_date) },
436
                });
437
            } else {
438
                // Unparsable object bytes: report as packed so as not to fail.
439
                lost.push(LostCommit {
440
                    sha,
441
                    action: if action.is_empty() { None } else { Some(action) },
442
                    timestamp,
443
                    packed: true,
444
                    subject: None,
445
                    author: None,
446
                    author_date: None,
447
                });
448
            }
449
        } else {
450
            lost.push(LostCommit {
451
                sha,
452
                action: if action.is_empty() { None } else { Some(action) },
453
                timestamp,
454
                packed: true,
455
                subject: None,
456
                author: None,
457
                author_date: None,
458
            });
459
        }
460
    }
461
462
    // Stable sort: timestamp descending, then sha ascending.
463
    lost.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then(a.sha.cmp(&b.sha)));
464
    let total_lost_candidates = lost.len();
465
    lost.truncate(max);
466
467
    let summary = Summary {
468
        total_lost_candidates,
469
        stashes_count: stash.len(),
470
    };
471
472
    Ok(Output {
473
        head,
474
        stash_entries: stash,
475
        lost_commits: lost,
476
        summary,
477
    })
478
}
479
480
fn update_candidate(map: &mut HashMap<String, (i64, String)>, sha: String, timestamp: i64, message: &str) {
481
    map.entry(sha)
482
        .and_modify(|(t, m)| {
483
            if timestamp > *t {
484
                *t = timestamp;
485
                *m = message.to_string();
486
            }
487
        })
488
        .or_insert((timestamp, message.to_string()));
489
}
490
491
fn handle(input: Input) -> Result<Output, Refusal> {
492
    scan(&RealHost, &input)
493
}
494
495
plugin_entry!(handle);
496
497
#[cfg(test)]
498
mod tests;
plugins/git-lost-work/src/tests.rs added +258

@@ -0,0 +1,258 @@

1
//! Unit tests for `git-lost-work` against a fake in-memory `.git` tree.
2
3
use super::*;
4
use openagents_pdk::{MountDirEntry, Refusal, RefusalCode};
5
use std::collections::BTreeMap;
6
7
const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
8
const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
9
const C: &str = "cccccccccccccccccccccccccccccccccccccccc";
10
const D: &str = "dddddddddddddddddddddddddddddddddddddddd";
11
const E: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
12
const ZERO: &str = "0000000000000000000000000000000000000000";
13
14
#[derive(Default)]
15
struct FakeHost {
16
    dirs: BTreeMap<(u32, String), MountDirListing>,
17
    files: BTreeMap<String, Vec<u8>>,
18
}
19
20
impl FakeHost {
21
    fn dir(&mut self, mount: u32, path: &str, entries: Vec<MountDirEntry>) {
22
        self.dirs.insert((mount, path.to_string()), MountDirListing { entries, truncated: false });
23
    }
24
    fn file(&mut self, path: &str, text: &str) {
25
        self.files.insert(path.to_string(), text.as_bytes().to_vec());
26
    }
27
    fn file_bytes(&mut self, path: &str, bytes: Vec<u8>) {
28
        self.files.insert(path.to_string(), bytes);
29
    }
30
}
31
32
impl Host for FakeHost {
33
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
34
        self.dirs
35
            .get(&(mount_index, path.to_string()))
36
            .cloned()
37
            .ok_or_else(|| Refusal::new(RefusalCode::FileUnreadable, "no such directory"))
38
    }
39
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
40
        self.files
41
            .get(path)
42
            .cloned()
43
            .ok_or_else(|| Refusal::new(RefusalCode::FileUnreadable, "no such file"))
44
    }
45
}
46
47
fn entry(name: &str, kind: &str, size: u64, mtime_ms: i64) -> MountDirEntry {
48
    MountDirEntry { name: name.to_string(), kind: kind.to_string(), size, mtime_ms }
49
}
50
51
fn zlib(text: &str) -> Vec<u8> {
52
    miniz_oxide::deflate::compress_to_vec_zlib(text.as_bytes(), 6)
53
}
54
55
fn commit_object(subject: &str, author: &str, timestamp: i64) -> String {
56
    format!(
57
        "commit {}\0tree ffffffffffffffffffffffffffffffffffffffff\nauthor {author} {timestamp} +0000\ncommitter {author} {timestamp} +0000\n\n{subject}\n",
58
        100 // placeholder size; parser only checks header prefix
59
    )
60
}
61
62
/// Standard repo with branch HEAD, packed-refs, loose and packed lost commits,
63
/// and one stash entry.
64
fn repo_with_lost() -> FakeHost {
65
    let mut h = FakeHost::default();
66
67
    // Root mount lists .git as a directory.
68
    h.dir(0, ".", vec![entry(".git", "dir", 0, 0)]);
69
70
    h.file(".git/HEAD", "ref: refs/heads/main\n");
71
    h.file(".git/refs/heads/main", &format!("{A}\n"));
72
    h.file(".git/packed-refs", &format!("{D} refs/heads/feature\n"));
73
74
    h.dir(0, ".git/refs/heads", vec![entry("main", "file", 40, 0)]);
75
    h.dir(0, ".git/logs/refs/heads", vec![entry("main", "file", 0, 0)]);
76
77
    h.file(
78
        ".git/logs/HEAD",
79
        &format!(
80
            "{ZERO} {A} Alice <a@b.com> 1700000000 +0000\tcommit (initial): init\n\
81
             {A} {B} Alice <a@b.com> 1700000100 +0000\tcommit: wip\n\
82
             {B} {A} Alice <a@b.com> 1700000200 +0000\treset: moving to main\n"
83
        ),
84
    );
85
    h.file(
86
        ".git/logs/refs/heads/main",
87
        &format!(
88
            "{ZERO} {A} Alice <a@b.com> 1700000000 +0000\tcommit (initial): init\n\
89
             {A} {C} Alice <a@b.com> 1700000050 +0000\tcherry-pick: c\n\
90
             {C} {A} Alice <a@b.com> 1700000060 +0000\treset: moving to a\n"
91
        ),
92
    );
93
    h.file(
94
        ".git/logs/refs/stash",
95
        &format!(
96
            "{ZERO} {E} Alice <a@b.com> 1700000300 +0000\tWIP on main: wip\n"
97
        ),
98
    );
99
100
    // Loose object for B (a real commit).
101
    let b_obj = commit_object("fix: bug", "Alice <a@b.com>", 1700000100);
102
    let b_obj_path = format!(".git/objects/{}/{}", &B[..2], &B[2..]);
103
    h.file_bytes(&b_obj_path, zlib(&b_obj));
104
105
    // C has no loose object; it is reported as packed.
106
    // E (stash) has no object; stashes do not need one.
107
108
    h
109
}
110
111
#[test]
112
fn finds_branch_head_and_reachable_tips() {
113
    let out = scan(&repo_with_lost(), &Input { max_lost_commits: None }).unwrap();
114
    assert_eq!(out.head.branch.as_deref(), Some("main"));
115
    assert_eq!(out.head.sha.as_deref(), Some(A));
116
}
117
118
#[test]
119
fn reports_lost_loose_and_packed_commits() {
120
    let out = scan(&repo_with_lost(), &Input { max_lost_commits: None }).unwrap();
121
    assert_eq!(out.lost_commits.len(), 2);
122
123
    let b = out.lost_commits.iter().find(|c| c.sha == B).unwrap();
124
    assert!(!b.packed);
125
    assert_eq!(b.subject.as_deref(), Some("fix: bug"));
126
    assert_eq!(b.author.as_deref(), Some("Alice <a@b.com>"));
127
    assert_eq!(b.author_date, Some(1700000100));
128
    assert_eq!(b.action.as_deref(), Some("reset: moving to main"));
129
    assert_eq!(b.timestamp, 1700000200);
130
131
    let c = out.lost_commits.iter().find(|c| c.sha == C).unwrap();
132
    assert!(c.packed);
133
    assert_eq!(c.subject, None);
134
    assert_eq!(c.author, None);
135
    assert_eq!(c.author_date, None);
136
    assert_eq!(c.action.as_deref(), Some("reset: moving to a"));
137
    assert_eq!(c.timestamp, 1700000060);
138
}
139
140
#[test]
141
fn reports_stash_entries() {
142
    let out = scan(&repo_with_lost(), &Input { max_lost_commits: None }).unwrap();
143
    assert_eq!(out.stash_entries.len(), 1);
144
    assert_eq!(out.stash_entries[0].selector, "stash@{0}");
145
    assert_eq!(out.stash_entries[0].sha, E);
146
    assert_eq!(out.stash_entries[0].message.as_deref(), Some("WIP on main: wip"));
147
    assert_eq!(out.stash_entries[0].timestamp, 1700000300);
148
}
149
150
#[test]
151
fn summary_counts_are_correct() {
152
    let out = scan(&repo_with_lost(), &Input { max_lost_commits: None }).unwrap();
153
    assert_eq!(out.summary.total_lost_candidates, 2);
154
    assert_eq!(out.summary.stashes_count, 1);
155
}
156
157
#[test]
158
fn max_lost_commits_caps_and_defaults_to_fifty() {
159
    let out = scan(&repo_with_lost(), &Input { max_lost_commits: Some(1) }).unwrap();
160
    assert_eq!(out.lost_commits.len(), 1);
161
    let out = scan(&repo_with_lost(), &Input { max_lost_commits: Some(999) }).unwrap();
162
    assert_eq!(out.lost_commits.len(), 2);
163
    let out = scan(&repo_with_lost(), &Input { max_lost_commits: None }).unwrap();
164
    assert_eq!(out.lost_commits.len(), 2);
165
}
166
167
#[test]
168
fn lost_commits_are_sorted_by_timestamp_desc_then_sha_asc() {
169
    let out = scan(&repo_with_lost(), &Input { max_lost_commits: None }).unwrap();
170
    let shas: Vec<&str> = out.lost_commits.iter().map(|c| c.sha.as_str()).collect();
171
    assert_eq!(shas, vec![B, C]);
172
    assert!(out.lost_commits[0].timestamp >= out.lost_commits[1].timestamp);
173
}
174
175
#[test]
176
fn detached_head_is_reported() {
177
    let mut h = FakeHost::default();
178
    h.dir(0, ".", vec![entry(".git", "dir", 0, 0)]);
179
    h.file(".git/HEAD", &format!("{B}\n"));
180
    h.file(".git/logs/HEAD", &format!("{ZERO} {A} Alice <a@b.com> 1700000000 +0000\tcommit (initial): init\n{A} {B} Alice <a@b.com> 1700000100 +0000\tcheckout: moving to b\n"));
181
    h.file(".git/refs/heads/main", &format!("{A}\n"));
182
    h.dir(0, ".git/refs/heads", vec![entry("main", "file", 40, 0)]);
183
    h.dir(0, ".git/logs/refs/heads", vec![]);
184
185
    let out = scan(&h, &Input { max_lost_commits: None }).unwrap();
186
    assert_eq!(out.head.branch, None);
187
    assert_eq!(out.head.sha.as_deref(), Some(B));
188
    // A is still a current ref tip (main), so it is not lost.
189
    assert!(out.lost_commits.is_empty());
190
}
191
192
#[test]
193
fn missing_git_directory_returns_empty_output() {
194
    let h = FakeHost::default();
195
    let out = scan(&h, &Input { max_lost_commits: None }).unwrap();
196
    assert_eq!(out, Output::default());
197
}
198
199
#[test]
200
fn malformed_reflog_lines_and_corrupt_objects_do_not_panic() {
201
    let mut h = repo_with_lost();
202
    // Append a malformed line to HEAD reflog.
203
    let mut head_log = String::from_utf8(h.files[".git/logs/HEAD"].clone()).unwrap();
204
    head_log.push_str("this is not a valid reflog line\n");
205
    h.file(".git/logs/HEAD", &head_log);
206
207
    // Overwrite B's object with corrupt zlib-looking bytes.
208
    h.file_bytes(&format!(".git/objects/{}/{}", &B[..2], &B[2..]), b"not zlib".to_vec());
209
210
    let out = scan(&h, &Input { max_lost_commits: None }).unwrap();
211
    // B is now reported as packed because the object could not be parsed.
212
    let b = out.lost_commits.iter().find(|c| c.sha == B).unwrap();
213
    assert!(b.packed);
214
    assert_eq!(b.subject, None);
215
}
216
217
#[test]
218
fn fallback_stash_ref_is_used_when_log_is_missing() {
219
    let mut h = repo_with_lost();
220
    h.files.remove(".git/logs/refs/stash");
221
    h.file(".git/refs/stash", &format!("{E}\n"));
222
    let out = scan(&h, &Input { max_lost_commits: None }).unwrap();
223
    assert_eq!(out.stash_entries.len(), 1);
224
    assert_eq!(out.stash_entries[0].selector, "stash@{0}");
225
    assert_eq!(out.stash_entries[0].sha, E);
226
    assert_eq!(out.stash_entries[0].message, None);
227
    assert_eq!(out.stash_entries[0].timestamp, 0);
228
}
229
230
#[test]
231
fn zlib_roundtrip_for_commit_object() {
232
    let text = commit_object("fix: bug", "Alice <a@b.com>", 1700000100);
233
    let compressed = zlib(&text);
234
    let decompressed = miniz_oxide::inflate::decompress_to_vec_zlib(&compressed).unwrap();
235
    let back = String::from_utf8_lossy(&decompressed);
236
    eprintln!("text={text:?}");
237
    eprintln!("back={back:?}");
238
    assert_eq!(text, back.as_ref());
239
}
240
241
#[test]
242
fn commit_object_parses_directly() {
243
    let text = commit_object("fix: bug", "Alice <a@b.com>", 1700000100);
244
    let compressed = zlib(&text);
245
    let result = crate::parse_commit_object(&compressed);
246
    eprintln!("result={result:?}");
247
    let (subject, author, date) = result.unwrap();
248
    assert_eq!(subject, "fix: bug");
249
    assert_eq!(author, "Alice <a@b.com>");
250
    assert_eq!(date, 1700000100);
251
}
252
253
#[test]
254
fn output_is_deterministic_across_runs() {
255
    let a = scan(&repo_with_lost(), &Input { max_lost_commits: None }).unwrap();
256
    let b = scan(&repo_with_lost(), &Input { max_lost_commits: None }).unwrap();
257
    assert_eq!(a, b);
258
}

This page updates live while a promote is in flight · changelog