foam 0.2.0

an issue tracker and agent memory that lives on a git ref
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
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use anyhow::{Context, Result, anyhow, bail};

/// A handle on the repository that contains `dir`.
#[derive(Debug, Clone)]
pub struct Git {
    dir: PathBuf,
}

/// One entry of a tree, as `git mktree` takes it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
    pub name: String,
    pub oid: String,
    pub kind: EntryKind,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
    Blob,
    Tree,
}

/// Where `HEAD` was when a write happened.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Stamp {
    pub commit: String,
    pub branch: String,
}

/// Where a stamped commit stands relative to `HEAD`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Distance {
    /// In this branch's history, this many commits back.
    Behind(u64),
    /// Not in this branch's history.
    Elsewhere,
    /// The commit is not in this repository at all.
    Unknown,
}

/// The result of `git merge-tree --write-tree`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Merged {
    pub tree: String,
    pub conflicts: Vec<Conflict>,
}

/// One conflicted path with its three blobs; a missing stage
/// means the path was absent from that side.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Conflict {
    pub path: String,
    pub base: Option<String>,
    pub ours: Option<String>,
    pub theirs: Option<String>,
}

/// The outcome of a push.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Push {
    Done,
    Rejected,
}

/// The outcome of a compare-and-swap on a ref.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Swap {
    Done,
    Lost,
}

impl Git {
    pub fn open(dir: &Path) -> Result<Git> {
        let git = Git {
            dir: dir.to_path_buf(),
        };
        git.run(&["rev-parse", "--git-dir"])
            .with_context(|| format!("{} is not inside a git repository", dir.display()))?;
        Ok(git)
    }

    fn command(&self, args: &[&str]) -> Command {
        let mut cmd = Command::new("git");
        cmd.arg("-C").arg(&self.dir).args(args);
        cmd
    }

    fn run(&self, args: &[&str]) -> Result<Vec<u8>> {
        self.run_with_input(args, &[])
    }

    fn run_with_input(&self, args: &[&str], input: &[u8]) -> Result<Vec<u8>> {
        let mut child = self
            .command(args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .context("failed to run git")?;
        // the child may fill its stdout pipe before it has
        // read all of its stdin, so feed it from a thread
        // while this one drains the output
        let mut stdin = child.stdin.take().expect("stdin was piped");
        let input = input.to_vec();
        let feeder = std::thread::spawn(move || stdin.write_all(&input));
        let out = child.wait_with_output()?;
        feeder.join().expect("feeder thread panicked")?;
        if !out.status.success() {
            bail!(
                "git {} failed: {}",
                args.join(" "),
                String::from_utf8_lossy(&out.stderr).trim()
            );
        }
        Ok(out.stdout)
    }

    /// The object id `rev` names, or `None` if nothing does.
    pub fn rev_parse(&self, rev: &str) -> Result<Option<String>> {
        let out = self
            .command(&["rev-parse", "--verify", "-q", rev])
            .stderr(Stdio::null())
            .output()?;
        if !out.status.success() {
            return Ok(None);
        }
        Ok(Some(String::from_utf8(out.stdout)?.trim_end().to_string()))
    }

    /// Every blob under `commit`, as `(path, oid)`.
    pub fn ls_tree(&self, commit: &str) -> Result<Vec<(String, String)>> {
        let out = self.run(&["ls-tree", "-r", "-z", "--full-tree", commit])?;
        let mut entries = Vec::new();
        for record in out.split(|b| *b == 0).filter(|r| !r.is_empty()) {
            let record = std::str::from_utf8(record)?;
            // "<mode> <type> <oid>\t<path>"
            let (meta, path) = record
                .split_once('\t')
                .ok_or_else(|| anyhow!("malformed ls-tree record: {record}"))?;
            let oid = meta
                .rsplit(' ')
                .next()
                .ok_or_else(|| anyhow!("malformed ls-tree record: {record}"))?;
            entries.push((path.to_string(), oid.to_string()));
        }
        Ok(entries)
    }

    /// The contents of each of `oids`, in order.
    pub fn cat_file_batch(&self, oids: &[&str]) -> Result<Vec<Vec<u8>>> {
        if oids.is_empty() {
            return Ok(Vec::new());
        }
        let mut input = oids.join("\n");
        input.push('\n');
        let out = self.run_with_input(&["cat-file", "--batch"], input.as_bytes())?;
        let mut blobs = Vec::with_capacity(oids.len());
        let mut rest = &out[..];
        for oid in oids {
            // "<oid> <type> <size>\n<content>\n"
            let nl = rest
                .iter()
                .position(|b| *b == b'\n')
                .ok_or_else(|| anyhow!("truncated cat-file output at {oid}"))?;
            let header = std::str::from_utf8(&rest[..nl])?;
            let size: usize = header
                .rsplit(' ')
                .next()
                .and_then(|s| s.parse().ok())
                .ok_or_else(|| anyhow!("object {oid} is missing: {header}"))?;
            let start = nl + 1;
            blobs.push(rest[start..start + size].to_vec());
            rest = &rest[start + size + 1..];
        }
        Ok(blobs)
    }

    pub fn hash_object(&self, content: &[u8]) -> Result<String> {
        let out = self.run_with_input(&["hash-object", "-w", "--stdin"], content)?;
        Ok(String::from_utf8(out)?.trim_end().to_string())
    }

    pub fn mktree(&self, entries: &[TreeEntry]) -> Result<String> {
        let mut input = String::new();
        for e in entries {
            let (mode, kind) = match e.kind {
                EntryKind::Blob => ("100644", "blob"),
                EntryKind::Tree => ("040000", "tree"),
            };
            input.push_str(&format!("{mode} {kind} {}\t{}\n", e.oid, e.name));
        }
        let out = self.run_with_input(&["mktree"], input.as_bytes())?;
        Ok(String::from_utf8(out)?.trim_end().to_string())
    }

    pub fn commit_tree(&self, tree: &str, parents: &[&str], message: &str) -> Result<String> {
        let mut args = vec!["commit-tree", tree];
        for p in parents {
            args.push("-p");
            args.push(p);
        }
        args.push("-m");
        args.push(message);
        let out = self.run(&args)?;
        Ok(String::from_utf8(out)?.trim_end().to_string())
    }

    /// Point `name` at `new`, provided it still points at `old`
    /// (`None` means it must not exist yet).
    pub fn update_ref(&self, name: &str, new: &str, old: Option<&str>) -> Result<Swap> {
        let out = self
            .command(&["update-ref", name, new, old.unwrap_or("")])
            .stderr(Stdio::piped())
            .output()?;
        if out.status.success() {
            return Ok(Swap::Done);
        }
        let err = String::from_utf8_lossy(&out.stderr);
        // git reports a failed old-value check as "cannot lock
        // ref", the same words as a real lock contention; both
        // clear on a retry so both are reported as Lost
        if err.contains("cannot lock ref") || err.contains("but expected") {
            return Ok(Swap::Lost);
        }
        bail!("git update-ref {name} failed: {}", err.trim());
    }

    pub fn head_stamp(&self) -> Result<Stamp> {
        let commit = self
            .rev_parse("HEAD")?
            .map(|oid| oid[..7].to_string())
            .unwrap_or_else(|| "none".to_string());
        let branch = self
            .command(&["symbolic-ref", "--short", "-q", "HEAD"])
            .stderr(Stdio::null())
            .output()
            .ok()
            .filter(|o| o.status.success())
            .map(|o| String::from_utf8_lossy(&o.stdout).trim_end().to_string())
            .unwrap_or_else(|| "detached".to_string());
        Ok(Stamp { commit, branch })
    }

    pub fn config(&self, key: &str) -> Option<String> {
        let out = self
            .command(&["config", "--get", key])
            .stderr(Stdio::null())
            .output()
            .ok()
            .filter(|o| o.status.success())?;
        let value = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
        (!value.is_empty()).then_some(value)
    }

    /// Whether `core.hooksPath` sends hooks somewhere other than
    /// this repository's own hooks directory.
    pub fn hooks_redirected(&self) -> bool {
        self.config("core.hooksPath").is_some()
    }

    pub fn distance(&self, commit: &str) -> Distance {
        if self
            .rev_parse(&format!("{commit}^{{commit}}"))
            .ok()
            .flatten()
            .is_none()
        {
            return Distance::Unknown;
        }
        let ancestor = self
            .command(&["merge-base", "--is-ancestor", commit, "HEAD"])
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if !ancestor {
            return Distance::Elsewhere;
        }
        let range = format!("{commit}..HEAD");
        self.command(&["rev-list", "--count", &range])
            .stderr(Stdio::null())
            .output()
            .ok()
            .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
            .map(Distance::Behind)
            .unwrap_or(Distance::Unknown)
    }

    /// The root of the working tree.
    pub fn toplevel(&self) -> Result<PathBuf> {
        let out = self.run(&["rev-parse", "--show-toplevel"])?;
        Ok(PathBuf::from(String::from_utf8(out)?.trim_end()))
    }

    pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
        let status = self
            .command(&["merge-base", "--is-ancestor", ancestor, descendant])
            .stderr(Stdio::null())
            .status()?;
        Ok(status.success())
    }

    /// Three-way merge `theirs` into `ours` without a checkout.
    pub fn merge_tree(&self, ours: &str, theirs: &str) -> Result<Merged> {
        let out = self
            .command(&[
                "merge-tree",
                "--write-tree",
                "--allow-unrelated-histories",
                "-z",
                ours,
                theirs,
            ])
            .stderr(Stdio::piped())
            .output()?;
        // exit 0 is clean, 1 is conflicts; anything else failed
        if !matches!(out.status.code(), Some(0 | 1)) {
            bail!(
                "git merge-tree failed: {}",
                String::from_utf8_lossy(&out.stderr).trim()
            );
        }
        let mut records = out.stdout.split(|b| *b == 0);
        let tree = std::str::from_utf8(records.next().unwrap_or_default())?.to_string();
        let mut conflicts: Vec<Conflict> = Vec::new();
        // "<mode> <oid> <stage>\t<path>" until an empty record
        // ends the section
        for record in records {
            if record.is_empty() {
                break;
            }
            let record = std::str::from_utf8(record)?;
            let (meta, path) = record
                .split_once('\t')
                .ok_or_else(|| anyhow!("malformed merge-tree record: {record}"))?;
            let mut fields = meta.split(' ');
            let (Some(_mode), Some(oid), Some(stage)) =
                (fields.next(), fields.next(), fields.next())
            else {
                bail!("malformed merge-tree record: {record}");
            };
            let entry = match conflicts.iter_mut().find(|c| c.path == path) {
                Some(c) => c,
                None => {
                    conflicts.push(Conflict {
                        path: path.to_string(),
                        ..Default::default()
                    });
                    conflicts.last_mut().unwrap()
                }
            };
            let slot = match stage {
                "1" => &mut entry.base,
                "2" => &mut entry.ours,
                "3" => &mut entry.theirs,
                _ => bail!("unexpected merge stage in: {record}"),
            };
            *slot = Some(oid.to_string());
        }
        Ok(Merged { tree, conflicts })
    }

    /// Whether `remote` has a ref named `name`.
    pub fn ls_remote(&self, remote: &str, name: &str) -> Result<bool> {
        let out = self
            .command(&["ls-remote", "--exit-code", remote, name])
            .stderr(Stdio::piped())
            .output()?;
        match out.status.code() {
            Some(0) => Ok(true),
            // 2 is "no matching refs"
            Some(2) => Ok(false),
            _ => bail!(
                "git ls-remote {remote} failed: {}",
                String::from_utf8_lossy(&out.stderr).trim()
            ),
        }
    }

    pub fn fetch(&self, remote: &str, refspec: &str) -> Result<()> {
        self.run(&["fetch", "--quiet", remote, refspec])?;
        Ok(())
    }

    pub fn push(&self, remote: &str, refspec: &str) -> Result<Push> {
        // the pre-push hook foam installs runs foam sync,
        // which would push again; the variable tells it
        // this push is already that
        let out = self
            .command(&["push", "--quiet", remote, refspec])
            .env("FOAM_IN_HOOK", "1")
            .stderr(Stdio::piped())
            .output()?;
        if out.status.success() {
            return Ok(Push::Done);
        }
        let err = String::from_utf8_lossy(&out.stderr);
        if err.contains("[rejected]")
            || err.contains("non-fast-forward")
            || err.contains("fetch first")
        {
            return Ok(Push::Rejected);
        }
        bail!("git push failed: {}", err.trim());
    }

    pub fn remote_url(&self, remote: &str) -> Option<String> {
        self.config(&format!("remote.{remote}.url"))
    }

    pub fn config_all(&self, key: &str) -> Vec<String> {
        self.command(&["config", "--get-all", key])
            .stderr(Stdio::null())
            .output()
            .ok()
            .filter(|o| o.status.success())
            .map(|o| {
                String::from_utf8_lossy(&o.stdout)
                    .lines()
                    .map(str::to_string)
                    .collect()
            })
            .unwrap_or_default()
    }

    pub fn config_add(&self, key: &str, value: &str) -> Result<()> {
        self.run(&["config", "--add", key, value])?;
        Ok(())
    }

    /// Where this repository's hooks live.
    pub fn hooks_dir(&self) -> Result<PathBuf> {
        let out = self.run(&["rev-parse", "--git-path", "hooks"])?;
        let path = PathBuf::from(String::from_utf8(out)?.trim_end());
        Ok(if path.is_absolute() {
            path
        } else {
            self.dir.join(path)
        })
    }

    /// The newest `limit` commits on `name` as `(oid, when, subject)`,
    /// only those whose subject mentions `word` when one is given.
    pub fn log(
        &self,
        name: &str,
        limit: usize,
        word: Option<&str>,
    ) -> Result<Vec<(String, String, String)>> {
        let limit = limit.to_string();
        let mut args = vec!["log", "--format=%H%x00%cI%x00%s", "-n", &limit];
        let grep;
        if let Some(w) = word {
            grep = format!("--grep={}", regex_escape(w));
            args.push(&grep);
        }
        args.push(name);
        let out = self.run(&args)?;
        let text = String::from_utf8(out)?;
        Ok(text
            .lines()
            .filter_map(|l| {
                let mut f = l.splitn(3, '\0');
                Some((
                    f.next()?.to_string(),
                    f.next()?.to_string(),
                    f.next()?.to_string(),
                ))
            })
            .collect())
    }
}

/// Escape `s` for git's basic regular expressions.
fn regex_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if "\\.[]*^$".contains(c) {
            out.push('\\');
        }
        out.push(c);
    }
    out
}