use anyhow::{Context, Result};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
pub const GUEST_GITDIR: &str = "/omh/shadow";
pub fn pointer_file() -> String {
format!("gitdir: {GUEST_GITDIR}\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Turn {
pub back: usize,
pub subject: String,
pub age: Option<u64>,
pub touched: Option<Touched>,
}
fn stranded_args() -> Vec<String> {
vec![
"rev-list".into(),
format!("--exclude={TURN_REF}"),
"--all".into(),
"--not".into(),
"HEAD".into(),
]
}
pub const TURN_REF: &str = "refs/omh/turn";
pub fn turn_hook_command(gitdir: &str, worktree: &str) -> String {
let g = &[
"git -C ",
worktree,
" --git-dir=",
gitdir,
" --work-tree=",
worktree,
]
.concat();
[
"{ i=$(mktemp)",
"; GIT_INDEX_FILE=$i ",
g,
" read-tree HEAD",
" || GIT_INDEX_FILE=$i ",
g,
" read-tree --empty",
"; GIT_INDEX_FILE=$i ",
g,
" add -A",
" && t=$(GIT_INDEX_FILE=$i ",
g,
" write-tree)",
" && p=$(",
g,
" rev-parse -q --verify ",
TURN_REF,
" || true)",
" && if [ -n \"$p\" ] && [ \"$(",
g,
" rev-parse \"$p^{tree}\")\" = \"$t\" ]",
"; then :",
"; else c=$(",
g,
" commit-tree \"$t\" ${p:+-p} ${p:+\"$p\"} -m \"turn end\")",
" && ",
g,
" update-ref ",
TURN_REF,
" \"$c\"",
"; fi; rm -f \"$i\"; } >/dev/null 2>&1 || true",
]
.concat()
}
pub fn turn_hook_for_the_sandbox() -> String {
turn_hook_command(GUEST_GITDIR, crate::container_workdir())
}
pub fn turn_hook_when() -> String {
format!("[ -d {GUEST_GITDIR} ]")
}
pub const GUEST_NOTE: &str = "/omh/shadow/omh-note";
pub fn note_file(gitdir: &std::path::Path) -> PathBuf {
gitdir.join(NOTE_NAME)
}
const NOTE_NAME: &str = "omh-note";
pub const GUEST_PRE_PUSH: &str = "/omh/shadow/hooks/pre-push";
pub const GUEST_CONFIG: &str = "/omh/shadow/config";
pub fn pre_push_hook() -> String {
format!("#!/bin/sh\ncat >&2 <<'OMH'\n{NO_PUSH}\nOMH\nexit 1\n")
}
pub struct Shadow {
pub gitdir: PathBuf,
pub seed_record: PathBuf,
pub landed_record: PathBuf,
pub branch: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Touched {
pub files: usize,
pub added: usize,
pub removed: usize,
pub uncounted: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkpoint {
pub number: usize,
pub id: String,
pub subject: String,
pub age: Option<u64>,
pub touched: Option<Touched>,
pub landed: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Checkpoints {
pub commits: Vec<Checkpoint>,
pub unreachable: usize,
pub replay_point_lost: bool,
pub uncommitted: usize,
}
impl Shadow {
pub fn new(shadow_dir: &Path, session_id: &str) -> Self {
Self {
gitdir: shadow_dir.join(format!("{session_id}.git")),
seed_record: shadow_dir.join(format!("{session_id}.seed")),
landed_record: shadow_dir.join(format!("{session_id}.landed")),
branch: format!("{session_id}-scratch"),
}
}
pub fn ensure(&self, worktree: &Path, excluded: &[String]) -> Result<()> {
if self.gitdir.exists() && self.seed_record.exists() {
Self::write_exclude(&self.gitdir, excluded)?;
Self::write_config(&self.gitdir)?;
return Ok(());
}
let parent = self
.gitdir
.parent()
.context("a shadow gitdir needs somewhere to live")?;
std::fs::create_dir_all(parent)?;
let building = parent.join(format!(
"{}.building",
self.gitdir
.file_name()
.unwrap_or_default()
.to_string_lossy()
));
let _ = std::fs::remove_dir_all(&building);
let _ = std::fs::remove_dir_all(&self.gitdir);
let made = Command::new("git")
.args(["init", "-q", "--bare", "--template=", "-b", &self.branch])
.arg(&building)
.output()
.context("creating the sandbox's repository")?;
anyhow::ensure!(
made.status.success(),
"git init: {}",
String::from_utf8_lossy(&made.stderr).trim()
);
git(&building, worktree, &["config", "core.bare", "false"])?;
Self::write_config(&building)?;
Self::write_exclude(&building, excluded)?;
Self::write_pre_push(&building)?;
git(&building, worktree, &["add", "-A", "."])?;
git(
&building,
worktree,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"--no-verify",
"--allow-empty",
"-m",
&seed_message(),
],
)?;
let seed = git(&building, worktree, &["rev-parse", "HEAD"])?;
std::fs::write(&self.seed_record, seed.trim())?;
std::fs::rename(&building, &self.gitdir)?;
Ok(())
}
pub fn seed(&self) -> Result<String> {
let seed = std::fs::read_to_string(&self.seed_record).with_context(|| {
format!(
"no seed recorded for this session at {}",
self.seed_record.display()
)
})?;
Ok(seed.trim().to_string())
}
pub fn landed(&self) -> Result<Option<String>> {
let landed = match std::fs::read_to_string(&self.landed_record) {
Ok(landed) => landed,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(e).with_context(|| {
format!(
"reading what {} last handed over, at {}",
self.branch,
self.landed_record.display()
)
})
}
};
let landed = landed.trim();
anyhow::ensure!(
!landed.is_empty(),
"{} is empty, which is what an interrupted write leaves behind. omh \
cannot tell what it last handed over, and will not guess. Take the \
files as they stand with `omh s commit -m`",
self.landed_record.display()
);
Ok(Some(landed.to_string()))
}
pub fn checkpoints(&self, worktree: &Path) -> Result<Checkpoints> {
let seed = self.seed()?;
let mut out = Checkpoints {
uncommitted: git(&self.gitdir, worktree, &["status", "--porcelain"])?
.lines()
.filter(|l| !l.trim().is_empty())
.count(),
unreachable: git_owned(&self.gitdir, worktree, &stranded_args())?
.lines()
.filter(|l| !l.trim().is_empty())
.count(),
..Checkpoints::default()
};
let handed: BTreeSet<String> = match self.landed()? {
Some(landed) if self.reaches(worktree, &landed)? => git(
&self.gitdir,
worktree,
&["rev-list", &format!("{seed}..{landed}")],
)?
.lines()
.map(str::to_string)
.collect(),
Some(_) => {
out.replay_point_lost = true;
BTreeSet::new()
}
None => BTreeSet::new(),
};
let raw = git(
&self.gitdir,
worktree,
&[
"log",
"--topo-order",
"--reverse",
"--format=%x00%H%x00%ct%x00%P%x00%s",
"--numstat",
&format!("{seed}..HEAD"),
],
)?;
out.commits = parse_log(&raw, &handed);
let counted: usize = git(
&self.gitdir,
worktree,
&["rev-list", "--count", &format!("{seed}..HEAD")],
)?
.trim()
.parse()
.unwrap_or(usize::MAX);
anyhow::ensure!(
counted == out.commits.len(),
"git listed {counted} commits in this sandbox and omh read {}. omh will not \
number a list it did not fully understand, because the numbers are what \
`--keep` takes. Read it directly:\n git --git-dir={} log",
out.commits.len(),
self.gitdir.display()
);
Ok(out)
}
pub fn show(
&self,
worktree: &Path,
number: usize,
what: crate::session::What,
) -> Result<String> {
let id = self.checkpoint_id(worktree, number)?;
let args = show_args(what, &id, "auto");
let args: Vec<&str> = args.iter().map(String::as_str).collect();
git(&self.gitdir, worktree, &args)
}
pub fn stream_show(
&self,
repo: &Path,
worktree: &Path,
number: usize,
colour: &str,
) -> Result<()> {
let id = self.checkpoint_id(worktree, number)?;
let args = show_args(crate::session::What::Patch, &id, colour);
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let status = Command::new("git")
.envs(GUEST_ENV)
.current_dir(worktree)
.args(NEUTRALISED.iter().flat_map(|kv| ["-c", kv]))
.arg("-c")
.arg(format!("core.pager={}", user_pager(repo)))
.arg("--git-dir")
.arg(&self.gitdir)
.arg("--work-tree")
.arg(worktree)
.args(guarded(&args))
.status()
.context("running git show")?;
anyhow::ensure!(status.success(), "git show exited {status}");
Ok(())
}
fn checkpoint_id(&self, worktree: &Path, number: usize) -> Result<String> {
let read = self.checkpoints(worktree)?;
if let Some(found) = read.commits.iter().find(|c| c.number == number) {
return Ok(found.id.clone());
}
anyhow::bail!(
"there is no checkpoint {number} in this session. {}",
match read.commits.len() {
0 => "The agent has not committed anything here yet".to_string(),
1 => "There is one, numbered 1".to_string(),
n => format!("They are numbered 1 to {n}"),
}
)
}
pub fn turn_log(&self, worktree: &Path) -> Result<Vec<Turn>> {
if self.turns(worktree)?.is_none() {
return Ok(Vec::new());
}
let raw = git(
&self.gitdir,
worktree,
&[
"log",
"--topo-order",
"--reverse",
"--format=%x00%H%x00%ct%x00%P%x00%s",
"--numstat",
TURN_REF,
],
)?;
let read = parse_log(&raw, &BTreeSet::new());
let last = read.len().saturating_sub(1);
Ok(read
.into_iter()
.enumerate()
.map(|(i, c)| Turn {
back: last - i,
subject: c.subject,
age: c.age,
touched: c.touched,
})
.collect())
}
pub fn turns(&self, worktree: &Path) -> Result<Option<usize>> {
if !self.gitdir.exists() {
return Ok(None);
}
let listed = git(
&self.gitdir,
worktree,
&["for-each-ref", "--format=%(refname)", TURN_REF],
)?;
if listed.trim().is_empty() {
return Ok(None);
}
let counted = git(&self.gitdir, worktree, &["rev-list", "--count", TURN_REF])?;
Ok(Some(counted.trim().parse().with_context(|| {
format!(
"counting turn snapshots, which answered `{}`",
counted.trim()
)
})?))
}
pub fn unkept(&self, worktree: &Path) -> Result<(usize, usize)> {
let from = match self.landed()? {
Some(landed) => landed,
None => self.seed()?,
};
let lines = |out: String| out.lines().filter(|l| !l.trim().is_empty()).count();
Ok((
lines(git(
&self.gitdir,
worktree,
&[
"rev-list",
&format!("--exclude={TURN_REF}"),
"--all",
"--reflog",
"--not",
&from,
],
)?),
lines(git(&self.gitdir, worktree, &["status", "--porcelain"])?),
))
}
pub fn checkpoint(&self, worktree: &Path, why: &str) -> Result<Option<String>> {
if git(&self.gitdir, worktree, &["status", "--porcelain"])?
.trim()
.is_empty()
{
return Ok(None);
}
git(&self.gitdir, worktree, &["add", "-A", "."])?;
git(
&self.gitdir,
worktree,
&["commit", "-q", "--no-verify", "-m", why],
)?;
Ok(Some(
git(&self.gitdir, worktree, &["rev-parse", "HEAD"])?
.trim()
.to_string(),
))
}
pub fn record_base_moved(
&self,
worktree: &Path,
onto: &str,
conflicted: &[String],
) -> Result<()> {
let mut add: Vec<&str> = vec!["add", "-A", "."];
let keep_out: Vec<String> = conflicted
.iter()
.map(|path| format!(":(exclude){path}"))
.collect();
add.extend(keep_out.iter().map(String::as_str));
git(&self.gitdir, worktree, &add)?;
let message = match conflicted.len() {
0 => format!("base moved to {onto}"),
n => format!(
"base moved to {onto}\n\n{n} file{} need resolving; `git status` names them.\n\
`git checkout -- <path>` takes back the version from before this.",
if n == 1 { "" } else { "s" }
),
};
git(
&self.gitdir,
worktree,
&[
"commit",
"-q",
"--no-verify",
"--allow-empty",
"-m",
&message,
],
)?;
Ok(())
}
pub fn leave_note(&self, text: &str) -> Result<()> {
let note = note_file(&self.gitdir);
let part = self.gitdir.join(format!("{NOTE_NAME}.part"));
let write = std::fs::write(&part, format!("{}\n", text.trim_end()))
.and_then(|()| std::fs::rename(&part, ¬e));
if write.is_err() {
let _ = std::fs::remove_file(&part);
}
write.with_context(|| format!("leaving a note at {}", note.display()))
}
fn reaches(&self, worktree: &Path, commit: &str) -> Result<bool> {
let out = Command::new("git")
.envs(GUEST_ENV)
.args(NEUTRALISED.iter().flat_map(|kv| ["-c", kv]))
.arg("--git-dir")
.arg(&self.gitdir)
.arg("--work-tree")
.arg(worktree)
.args(["merge-base", "--is-ancestor", commit, "HEAD"])
.output()
.context("running git")?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => anyhow::bail!(
"git merge-base --is-ancestor {commit} HEAD: {}",
crate::out::untrusted(String::from_utf8_lossy(&out.stderr).trim())
),
}
}
pub fn preflight(&self, worktree: &Path) -> Result<()> {
anyhow::ensure!(
self.gitdir.exists(),
"{} has no sandbox repository — nothing has run in it yet, so there \
are no commits of its own to keep. `omh s commit -m` takes the \
files as they are",
worktree.display()
);
let attached = Command::new("git")
.arg("--git-dir")
.arg(&self.gitdir)
.args(["symbolic-ref", "-q", "HEAD"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
anyhow::ensure!(
attached,
"the sandbox's repository is on a detached HEAD, so a harvest would \
take only what that commit can reach. Put it back on a branch \
first:\n git --git-dir={} checkout {}",
self.gitdir.display(),
self.branch
);
for marker in ["rebase-merge", "rebase-apply", "MERGE_HEAD"] {
anyhow::ensure!(
!self.gitdir.join(marker).exists(),
"the sandbox's repository has a {marker} in progress — finish or \
abort it before harvesting, or the harvest takes the half of it \
that happens to be reachable"
);
}
let stranded = git_owned(&self.gitdir, worktree, &stranded_args())?;
let stranded: Vec<&str> = stranded.lines().take(3).collect();
anyhow::ensure!(
stranded.is_empty(),
"the sandbox's repository has commits no branch it is on can reach \
({}…) — they would be dropped in silence. Merge or delete them \
first: git --git-dir={} log --all --not HEAD",
stranded.join(", "),
self.gitdir.display()
);
Ok(())
}
pub fn harvest(
&self,
repo: &Path,
worktree: &Path,
branch: &str,
carried: &[String],
keep: Keep,
) -> Result<usize> {
self.preflight(worktree)?;
if !matches!(keep, Keep::These(_))
&& !git(&self.gitdir, worktree, &["status", "--porcelain"])?
.trim()
.is_empty()
{
git(&self.gitdir, worktree, &["add", "-A", "."])?;
git(
&self.gitdir,
worktree,
&["commit", "-q", "--no-verify", "-m", "Work in progress"],
)?;
}
let seed = self.seed()?;
let scratch = format!("refs/omh/{}/harvest", self.branch);
git_in(
repo,
&[
"-c",
"protocol.file.allow=always",
"fetch",
"-q",
&self.gitdir.to_string_lossy(),
&format!("+HEAD:{scratch}"),
],
)?;
let from = match self.landed()? {
Some(landed) => {
let reaches =
git_in(repo, &["merge-base", "--is-ancestor", &landed, &scratch]).is_ok();
anyhow::ensure!(
reaches,
"the sandbox's history no longer reaches {}, which is what omh last \
kept from it — a `reset --hard` or a rebase below that point, or a \
record git can no longer read as a commit. omh will not guess which \
commits are new. Take the files as they stand with `omh s commit -m`",
&landed[..landed.len().min(8)]
);
landed
}
None => seed.clone(),
};
let range = format!("{from}..{scratch}");
let count: usize = git_in(repo, &["rev-list", "--count", &range])?
.trim()
.parse()
.with_context(|| format!("counting what {} has to hand over", self.branch))?;
if count == 0 {
git_in(repo, &["update-ref", "-d", &scratch])?;
return Ok(0);
}
self.refuse_carried(repo, &range, carried)?;
let head = git_in(worktree, &["rev-parse", "--abbrev-ref", "HEAD"])?;
anyhow::ensure!(
head.trim() == branch,
"{} is on {} rather than {branch}; omh will not move a branch the \
session is not on",
worktree.display(),
head.trim()
);
let before = git_in(repo, &["rev-parse", branch])?.trim().to_string();
let common = git_in(repo, &["rev-parse", "--git-common-dir"])?;
let replant = repo
.join(common.trim())
.join(format!("omh-harvest-{}", self.branch));
let _ = git_in(
repo,
&["worktree", "remove", "--force", &replant.to_string_lossy()],
);
git_in(
repo,
&[
"worktree",
"add",
"-q",
"--detach",
&replant.to_string_lossy(),
&scratch,
],
)?;
let curated = Self::replant(&replant, branch, &from, &keep, &scratch)
.and_then(|()| Self::stamp(&replant, &before));
let landed = curated
.and_then(|()| git_in(&replant, &["rev-parse", "HEAD"]))
.and_then(|tip| {
let tip = tip.trim().to_string();
let n = git_in(
&replant,
&["rev-list", "--count", &format!("{before}..{tip}")],
)?;
let n: usize = n
.trim()
.parse()
.with_context(|| format!("counting what landed on {branch}"))?;
Ok((tip, n))
});
let _ = git_in(
repo,
&["worktree", "remove", "--force", &replant.to_string_lossy()],
);
let (tip, landed) = landed?;
git_in(
repo,
&["update-ref", &format!("refs/heads/{branch}"), &tip, &before],
)
.map_err(|e| {
anyhow::anyhow!(
"{e}\n\n{branch} moved while the harvest was running, so nothing was \
written. The work is still at {scratch} — run `omh s commit \
--keep` again"
)
})?;
let landed_on = |what: &str| {
format!(
"{what}, but {branch} already has the work — the harvest itself \
succeeded. A later `--keep` may offer those commits again"
)
};
let handed_over = match &keep {
Keep::These(taken) => Self::advanced_past(repo, &from, &scratch, taken)
.with_context(|| landed_on("omh could not work out what it handed over"))?,
_ => git_in(repo, &["rev-parse", &scratch])
.with_context(|| landed_on("omh could not read back what it handed over"))?,
};
std::fs::write(&self.landed_record, handed_over.trim())
.with_context(|| landed_on("omh could not record what it handed over"))?;
git_in(repo, &["update-ref", "-d", &scratch])
.with_context(|| landed_on("omh could not clean up its own scratch ref"))?;
git_in(worktree, &["reset", "-q", "--mixed"])
.with_context(|| landed_on("omh could not refresh the session's index"))?;
Ok(landed)
}
fn replant(at: &Path, branch: &str, seed: &str, keep: &Keep, scratch: &str) -> Result<()> {
if let Keep::These(ids) = keep {
git_in(at, &["reset", "-q", "--hard", branch])
.map_err(|e| anyhow::anyhow!("{e}\n\n{}", Self::replant_failed(scratch)))?;
let mut args = vec!["cherry-pick", "--empty=drop"];
args.extend(ids.iter().map(String::as_str));
let out = Command::new("git")
.current_dir(at)
.args(NEUTRALISED.iter().flat_map(|kv| ["-c", kv]))
.args(&args)
.output()
.context("running git cherry-pick")?;
if out.status.success() {
return Ok(());
}
let said = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let said: Vec<&str> = said
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("hint:"))
.collect();
return Err(anyhow::anyhow!(
"{}\n\n{}",
crate::out::untrusted(&said.join("\n")),
Self::replant_failed(scratch)
));
}
let mut args = vec!["rebase", "--onto", branch, seed, "--empty=drop"];
let curate = *keep == Keep::Edit;
match keep {
Keep::Edit => args.push("-i"),
_ => args.push("-q"),
}
if curate {
let ok = Command::new("git")
.current_dir(at)
.args(NEUTRALISED.iter().flat_map(|kv| ["-c", kv]))
.args(&args)
.status()
.context("running git rebase -i")?;
return if ok.success() {
Ok(())
} else {
Err(Self::replant_failed(scratch))
};
}
git_in(at, &args)
.map(|_| ())
.map_err(|e| anyhow::anyhow!("{e}\n\n{}", Self::replant_failed(scratch)))
}
fn advanced_past(repo: &Path, from: &str, scratch: &str, taken: &[String]) -> Result<String> {
let ordered = git_in(
repo,
&["rev-list", "--reverse", &format!("{from}..{scratch}")],
)?;
let mut point = from.to_string();
for id in ordered.lines().map(str::trim).filter(|id| !id.is_empty()) {
if !taken.iter().any(|t| t == id) {
break;
}
point = id.to_string();
}
Ok(point)
}
fn replant_failed(scratch: &str) -> anyhow::Error {
anyhow::anyhow!(
"the branch is untouched and nothing is lost — omh fetched the work \
before replanting, and it is still at {scratch}. Look at it with \
`git log {scratch}`, or take the files without the history using \
`omh s commit -m`"
)
}
fn stamp(at: &Path, from: &str) -> Result<()> {
let exec = format!(
"git -c user.name='{AUTHOR_NAME}' -c user.email='{AUTHOR_EMAIL}' \
commit -q --amend --no-edit --reset-author --allow-empty"
);
git_in(
at,
&[
"rebase", "-q", "--onto", from, from, "HEAD", "--exec", &exec,
],
)
.map(|_| ())
}
fn refuse_carried(&self, repo: &Path, range: &str, carried: &[String]) -> Result<()> {
for rel in carried {
let rel = rel.trim().trim_end_matches('/');
let found = git_in(repo, &["log", "--oneline", range, "--", rel])?;
if let Some(line) = found.lines().next().map(crate::out::untrusted) {
anyhow::bail!(
"{rel} is a carried file and {line} has it. omh will not \
rewrite your history to hide a secret — drop that commit in \
the sandbox and harvest again, or take the files without the \
history with `omh s commit -m`"
);
}
for needle in Self::needles(&repo.join(rel))? {
for (how, args) in [
("contains", vec!["log", "--oneline", "-S", &needle, range]),
(
"mentions",
vec!["log", "--oneline", "-F", "--grep", &needle, range],
),
] {
let hit = git_in(repo, &args)?;
if let Some(line) = hit.lines().next().map(crate::out::untrusted) {
anyhow::bail!(
"{line} {how} a line from {rel}, which you carried in. \
omh will not rewrite your history to hide a secret — \
drop that commit in the sandbox and harvest again"
);
}
}
}
}
Ok(())
}
fn needles(at: &Path) -> Result<Vec<String>> {
if at.is_dir() {
let mut out = Vec::new();
for entry in std::fs::read_dir(at)
.with_context(|| format!("reading carried directory {}", at.display()))?
.flatten()
{
out.extend(Self::needles(&entry.path())?);
}
return Ok(out);
}
if !at.exists() {
return Ok(Vec::new());
}
let body = match std::fs::read_to_string(at) {
Ok(body) => body,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => return Ok(Vec::new()),
Err(e) => {
return Err(e).with_context(|| format!("reading carried file {}", at.display()))
}
};
Ok(body
.lines()
.map(str::trim)
.filter(|l| l.len() >= 12 && !l.starts_with('#') && !l.starts_with("//"))
.map(str::to_string)
.collect())
}
fn write_config(gitdir: &Path) -> Result<()> {
const KEEP: [&str; 9] = [
"core.repositoryformatversion",
"core.filemode",
"core.bare",
"core.worktree",
"core.ignorecase",
"core.precomposeunicode",
"core.symlinks",
"extensions.objectformat",
"extensions.refstorage",
];
let listed = Command::new("git")
.arg("--git-dir")
.arg(gitdir)
.args(["config", "--list", "--local", "--name-only"])
.output()
.with_context(|| format!("reading the config of {}", gitdir.display()))?;
anyhow::ensure!(
listed.status.success(),
"reading the config of {}: {}",
gitdir.display(),
crate::out::untrusted(String::from_utf8_lossy(&listed.stderr).trim())
);
let listed = String::from_utf8_lossy(&listed.stdout);
let keys: std::collections::BTreeSet<&str> = listed
.lines()
.map(str::trim)
.filter(|k| !k.is_empty())
.collect();
for key in keys {
if KEEP.contains(&key) || key == "user.name" || key == "user.email" {
continue;
}
let dropped = Command::new("git")
.arg("--git-dir")
.arg(gitdir)
.args(["config", "--unset-all", key])
.output()
.with_context(|| format!("dropping {key} from {}", gitdir.display()))?;
anyhow::ensure!(
dropped.status.success(),
"git config --unset-all {key}: {}",
String::from_utf8_lossy(&dropped.stderr).trim()
);
}
for (key, value) in [("user.name", AUTHOR_NAME), ("user.email", AUTHOR_EMAIL)] {
let set = Command::new("git")
.arg("--git-dir")
.arg(gitdir)
.args(["config", key, value])
.output()
.with_context(|| format!("setting {key} on {}", gitdir.display()))?;
anyhow::ensure!(
set.status.success(),
"git config {key}: {}",
String::from_utf8_lossy(&set.stderr).trim()
);
}
Ok(())
}
pub fn reap(&self) {
let _ = std::fs::remove_dir_all(&self.gitdir);
let _ = std::fs::remove_file(&self.seed_record);
let _ = std::fs::remove_file(&self.landed_record);
}
fn write_exclude(gitdir: &Path, excluded: &[String]) -> Result<()> {
let info = gitdir.join("info");
std::fs::create_dir_all(&info).with_context(|| format!("preparing {}", info.display()))?;
let body: String = excluded.iter().map(|n| format!("{n}\n")).collect();
let at = info.join("exclude");
std::fs::write(&at, body).with_context(|| format!("writing {}", at.display()))?;
Ok(())
}
fn write_pre_push(gitdir: &Path) -> Result<()> {
let hooks = gitdir.join("hooks");
std::fs::create_dir_all(&hooks)?;
let hook = hooks.join("pre-push");
std::fs::write(&hook, pre_push_hook())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
}
const AUTHOR_NAME: &str = "omh sandbox";
const AUTHOR_EMAIL: &str = "sandbox@omh.invalid";
pub const ARRANGEMENT: &str = "This repository is the sandbox's own, and it is not the branch \
anyone reviews. Commit as often as you like — that is what it is for, and `git reset --hard` \
back to a checkpoint is yours to use. What reaches the person you are working with is the \
state of the files, which they read with `omh s diff` and commit with `omh s commit` on the \
host, pushing with `omh s push` when they are ready. Your commit messages here stay here.\n\n\
There is nothing to push and no remote to push to, and adding one will not work either — \
the config is not yours to write. That is the arrangement rather than a fault to repair. Say \
so rather than offering to push, and do not offer to commit on the host — that is theirs to \
do.\n\n\
There is no history here — this starts at one commit holding the files as they were when the \
session opened, so `git log` and `git blame` have nothing older to tell you. And the branch \
it came from may move while you work, changing files under you; neither is a fault.";
pub fn note_for(base: &str, moved: usize, conflicted: usize) -> String {
let arrived = if moved == 0 {
format!(
"{base} moved backwards under you while this session was stopped — it now points \
at a commit this session already had. `git show HEAD` is exactly what changed."
)
} else {
format!(
"{base} moved {moved} commit{} under you while this session was stopped. \
`git show HEAD` is exactly what arrived.",
if moved == 1 { "" } else { "s" }
)
};
match conflicted {
0 => format!("{arrived} It merged cleanly — nothing needs deciding."),
n => format!(
"{arrived} {n} file{} still need{} resolving: `git status` names them, and \
`git checkout -- <path>` takes back the version from before the merge.",
if n == 1 { "" } else { "s" },
if n == 1 { "s" } else { "" }
),
}
}
pub fn seed_message() -> String {
format!("The session starts here.\n\n{ARRANGEMENT}")
}
const NO_PUSH: &str =
"omh: nothing to push from here. This repository is the sandbox's own and has no remote — \
your work reaches the outside through the host, where `omh s commit` puts it on the branch \
and `omh s push` sends it. Say that rather than trying to push yourself.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Keep {
All,
These(Vec<String>),
Edit,
}
pub fn chosen(spec: &str, available: usize) -> Result<Vec<usize>> {
let range = || match available {
0 => "this session has no checkpoints to keep".to_string(),
1 => "there is one, numbered 1".to_string(),
n => format!("they are numbered 1 to {n}"),
};
let number = |raw: &str| -> Result<usize> {
let n: usize = raw.trim().parse().map_err(|_| {
anyhow::anyhow!("`{spec}` is not a list of checkpoint numbers — {}", range())
})?;
anyhow::ensure!(
n >= 1 && n <= available,
"there is no checkpoint {n} in this session — {}",
range()
);
Ok(n)
};
let mut out: Vec<usize> = Vec::new();
anyhow::ensure!(
!spec.trim().is_empty(),
"no checkpoints named. `--keep` on its own takes all of them; \
`--keep 1,3-4` takes those — {}",
range()
);
for part in spec.split(',') {
match part.split_once('-') {
Some((from, to)) => {
let (from, to) = (number(from)?, number(to)?);
anyhow::ensure!(
from <= to,
"`{spec}` runs backwards at `{}`. omh will not guess whether that means \
{to} to {from} or a typo — name them in the order you want them",
part.trim()
);
out.extend(from..=to);
}
None => out.push(number(part)?),
}
}
for (at, n) in out.iter().enumerate() {
anyhow::ensure!(
!out[..at].contains(n),
"`{spec}` names checkpoint {n} twice. Applying it twice is not what the list \
showed you"
);
}
Ok(out)
}
pub(crate) fn options_in(help: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in help.lines() {
let column = line.trim_start();
let column = column.split(" ").next().unwrap_or_default().trim();
if !column.starts_with('-') {
continue;
}
for token in column.split(',') {
let token = token.trim();
let token = token
.split([' ', '='])
.next()
.unwrap_or_default()
.trim_end_matches(['[', '<', '(']);
if token.len() < 2 || !token.starts_with('-') {
continue;
}
out.insert(token.to_string());
if let Some(rest) = token.strip_prefix("--[no-]") {
out.insert(format!("--{rest}"));
out.insert(format!("--no-{rest}"));
}
}
}
out
}
pub fn git_supports(verb: &str, option: &str) -> Result<bool> {
let out = Command::new("git")
.args([verb, "-h"])
.output()
.with_context(|| format!("asking git whether `{verb}` takes `{option}`"))?;
let said = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let options = options_in(&said);
anyhow::ensure!(
!options.is_empty(),
"`git {verb} -h` listed no options at all, so omh cannot tell what this git \
supports. It said: {}",
crate::out::untrusted(said.trim())
);
Ok(options.contains(option.trim_end_matches('=')))
}
fn user_pager(repo: &Path) -> String {
Command::new("git")
.current_dir(repo)
.args(["var", "GIT_PAGER"])
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|pager| !pager.is_empty())
.unwrap_or_else(|| "less".to_string())
}
fn show_args(what: crate::session::What, id: &str, colour: &str) -> Vec<String> {
[
"show",
what.flag(),
"--first-parent",
&format!("--color={colour}"),
id,
]
.iter()
.map(|a| a.to_string())
.collect()
}
const NEUTRALISED: [&str; 8] = [
"commit.gpgsign=false",
"core.hooksPath=",
"core.pager=cat",
"core.fsmonitor=",
"core.sshCommand=",
"protocol.file.allow=never",
"diff.external=",
"core.useReplaceRefs=false",
];
const GUEST_ENV: [(&str, &str); 1] = [("GIT_GRAFT_FILE", "/dev/null")];
fn guarded(args: &[&str]) -> Vec<String> {
let mut out: Vec<String> = args.iter().map(|a| a.to_string()).collect();
if matches!(args.first(), Some(&"log" | &"show" | &"diff" | &"rev-list")) {
out.splice(
1..1,
["--no-textconv".to_string(), "--no-ext-diff".to_string()],
);
}
out
}
fn git_in(at: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.current_dir(at)
.envs(GUEST_ENV)
.args(NEUTRALISED.iter().flat_map(|kv| ["-c", kv]))
.args(guarded(args))
.output()
.context("running git")?;
if !out.status.success() {
anyhow::bail!(
"git {}: {}",
args.join(" "),
crate::out::untrusted(String::from_utf8_lossy(&out.stderr).trim())
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn parse_log(raw: &str, handed: &BTreeSet<String>) -> Vec<Checkpoint> {
let mut commits: Vec<Checkpoint> = Vec::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.ok();
for line in raw.lines() {
if let Some(header) = line.strip_prefix('\0') {
let mut fields = header.splitn(4, '\0');
let id = fields.next().unwrap_or_default().to_string();
let at: Option<u64> = fields.next().and_then(|at| at.trim().parse().ok());
let merge = fields
.next()
.is_some_and(|parents| parents.split_whitespace().count() > 1);
commits.push(Checkpoint {
number: commits.len() + 1,
landed: handed.contains(&id),
id,
subject: fields.next().unwrap_or_default().to_string(),
age: match (now, at) {
(Some(now), Some(at)) => Some(now.saturating_sub(at)),
_ => None,
},
touched: (!merge).then(Touched::default),
});
continue;
}
let Some(Some(touched)) = commits.last_mut().map(|c| &mut c.touched) else {
continue;
};
let mut counts = line.split('\t');
let (Some(added), Some(removed)) = (counts.next(), counts.next()) else {
continue;
};
touched.files += 1;
match (added.parse::<usize>(), removed.parse::<usize>()) {
(Ok(added), Ok(removed)) => {
touched.added += added;
touched.removed += removed;
}
_ => touched.uncounted += 1,
}
}
commits
}
fn git_owned(gitdir: &Path, worktree: &Path, args: &[String]) -> Result<String> {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
git(gitdir, worktree, &borrowed)
}
fn git(gitdir: &Path, worktree: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.envs(GUEST_ENV)
.args(NEUTRALISED.iter().flat_map(|kv| ["-c", kv]))
.current_dir(worktree)
.arg("--git-dir")
.arg(gitdir)
.arg("--work-tree")
.arg(worktree)
.args(guarded(args))
.output()
.context("running git")?;
if !out.status.success() {
anyhow::bail!(
"git {}: {}",
args.join(" "),
crate::out::untrusted(String::from_utf8_lossy(&out.stderr).trim())
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("checkout");
std::fs::create_dir_all(&root).unwrap();
let run = |args: &[&str]| {
Command::new("git")
.current_dir(&root)
.args(args)
.output()
.unwrap();
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", "t@example.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(root.join("f.txt"), "base\n").unwrap();
run(&["add", "-A"]);
run(&[
"commit",
"-q",
"-m",
"a commit only the host should know about",
]);
let wt = dir.path().join("wt");
run(&[
"worktree",
"add",
"-q",
"-b",
"omh/s01",
wt.to_str().unwrap(),
]);
std::fs::write(wt.join("f.txt"), "base\n").unwrap();
std::fs::write(wt.join(".env"), "SECRET=1\n").unwrap();
let shadow_dir = dir.path().join("shadow");
std::fs::create_dir_all(&shadow_dir).unwrap();
(dir, wt, shadow_dir)
}
fn head_of(repo: &Path) -> String {
let out = Command::new("git")
.current_dir(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn the_note_says_what_arrived_and_what_is_left_in_the_words_for_it() {
let one = note_for("main", 1, 0);
assert!(
one.contains("moved 1 commit under"),
"one commit, not `1 commits`: {one}"
);
assert!(
one.contains("nothing needs deciding"),
"and nothing is waiting: {one}"
);
assert!(
!one.contains("resolving"),
"a clean merge does not mention resolving anything: {one}"
);
let many = note_for("develop", 12, 2);
assert!(
many.contains("develop moved 12 commits"),
"the branch by name — a session's base is not always `main`: {many}"
);
assert!(
many.contains("2 files still need resolving"),
"and what is left, in the plural: {many}"
);
let single = note_for("main", 3, 1);
assert!(
single.contains("1 file still needs resolving"),
"one file *needs*, not *need*: {single}"
);
let back = note_for("release-1.0", 0, 0);
assert!(
!back.contains("0 commit"),
"a count of nothing is not a sentence: {back}"
);
assert!(
back.contains("moved backwards") && back.contains("release-1.0"),
"it says what actually happened, by name: {back}"
);
assert!(
back.contains("what changed") && !back.contains("what arrived"),
"and does not claim something arrived when the base went back: {back}"
);
for note in [&one, &many, &single, &back] {
assert!(
note.contains("git show HEAD"),
"every shape says where to read it: {note}"
);
assert!(
note.len() < 320,
"the note has grown from a sentence into a paragraph: {} B",
note.len()
);
assert!(!note.contains(" "), "a fold's indentation shipped: {note}");
}
}
fn turn_snapshot(s: &Shadow, wt: &Path) {
let body = turn_hook_command(s.gitdir.to_str().unwrap(), wt.to_str().unwrap());
let out = Command::new("sh")
.arg("-c")
.arg(&body)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.unwrap();
let _ = out;
}
fn checkpoint(s: &Shadow, wt: &Path, file: &str, body: &str, subject: &str) -> String {
std::fs::write(wt.join(file), body).unwrap();
git(&s.gitdir, wt, &["add", "-A", "."]).unwrap();
git(
&s.gitdir,
wt,
&["commit", "-q", "--no-verify", "-m", subject],
)
.unwrap();
git(&s.gitdir, wt, &["rev-parse", "HEAD"])
.unwrap()
.trim()
.to_string()
}
#[test]
fn a_second_sync_replaces_the_note_rather_than_stacking_on_it() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
s.leave_note(¬e_for("main", 3, 0)).unwrap();
s.leave_note(¬e_for("main", 1, 2)).unwrap();
let left = std::fs::read_to_string(note_file(&s.gitdir)).unwrap();
assert!(
left.contains("moved 1 commit") && !left.contains("moved 3 commits"),
"the second note is the one that is there: {left}"
);
assert_eq!(left.lines().count(), 1, "one sentence, not two: {left}");
let strays: Vec<_> = std::fs::read_dir(&s.gitdir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.filter(|n| n.contains("omh-note") && n != "omh-note")
.collect();
assert!(strays.is_empty(), "no scaffolding left behind: {strays:?}");
}
#[test]
fn a_note_that_cannot_be_written_leaves_nothing_claiming_to_be_one() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::create_dir(note_file(&s.gitdir)).unwrap();
let why = s.leave_note(¬e_for("main", 3, 0)).unwrap_err();
assert!(
format!("{why:#}").contains("omh-note"),
"the failure names the file: {why:#}"
);
let strays: Vec<_> = std::fs::read_dir(&s.gitdir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.filter(|n| n.contains("omh-note") && n != "omh-note")
.collect();
assert!(
strays.is_empty(),
"and the half-written one is cleaned up rather than left to be read: {strays:?}"
);
}
#[test]
fn a_turn_photographs_the_tree_and_leaves_the_agents_own_state_alone() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let head = |what: &str| git(&s.gitdir, &wt, &["rev-parse", what]).unwrap();
let status = || git(&s.gitdir, &wt, &["status", "--porcelain"]).unwrap();
assert_eq!(
s.turns(&wt).unwrap(),
None,
"a session that has never finished a turn has no ref, which is not \
the same answer as a count of none"
);
std::fs::write(wt.join("in-flight.rs"), "fn later() {}\n").unwrap();
let before = (head("HEAD"), status());
turn_snapshot(&s, &wt);
assert_eq!(
s.turns(&wt).unwrap(),
Some(1),
"a dirty turn is photographed"
);
assert_eq!(
(head("HEAD"), status()),
before,
"and HEAD and the working tree are exactly as the agent left them"
);
turn_snapshot(&s, &wt);
assert_eq!(
s.turns(&wt).unwrap(),
Some(1),
"a turn that changed nothing writes nothing"
);
std::fs::write(wt.join("in-flight.rs"), "fn later() { todo!() }\n").unwrap();
turn_snapshot(&s, &wt);
assert_eq!(
s.turns(&wt).unwrap(),
Some(2),
"and the next change is its own"
);
assert_eq!(
git(&s.gitdir, &wt, &["rev-list", "--count", TURN_REF])
.unwrap()
.trim(),
"2",
"the snapshots chain"
);
let broken = Shadow::new(&shadow_dir, "s99");
std::fs::create_dir_all(&broken.gitdir).unwrap();
assert!(
broken.turns(&wt).is_err(),
"a directory that is not a repository has no answer, not zero"
);
let shipped = turn_hook_for_the_sandbox();
assert!(
shipped.contains("i=$(mktemp)") && !shipped.contains("/omh/shadow/index"),
"a fresh index, never the agent's staging area: {shipped}"
);
assert!(
shipped.contains("rm -f \"$i\""),
"and it is cleaned up, so nothing accumulates in the mount: {shipped}"
);
assert!(
status().contains("in-flight.rs"),
"the agent still has its work to commit: {}",
status()
);
}
#[test]
fn a_reflog_the_agent_turned_on_does_not_survive_the_next_launch() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
git(
&s.gitdir,
&wt,
&["config", "core.logAllRefUpdates", "always"],
)
.unwrap();
assert_eq!(
git(
&s.gitdir,
&wt,
&["config", "--get", "core.logallrefupdates"]
)
.unwrap()
.trim(),
"always",
"the agent can set it"
);
s.ensure(&wt, &[]).unwrap();
let after = Command::new("git")
.arg("--git-dir")
.arg(&s.gitdir)
.args(["config", "--get", "core.logallrefupdates"])
.output()
.unwrap();
assert!(
!after.status.success(),
"and the next launch takes it away, so the default the exclusion \
relies on is the one in force: {after:?}"
);
}
#[test]
fn a_hook_that_cannot_do_its_job_still_ends_the_turn_quietly() {
let (_d, wt, shadow_dir) = fixture();
let run = |gitdir: &str, worktree: &str| {
let out = Command::new("sh")
.arg("-c")
.arg(turn_hook_command(gitdir, worktree))
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.unwrap();
assert!(
out.status.success() && out.stdout.is_empty() && out.stderr.is_empty(),
"a turn ends quietly whatever the hook found: {out:?}"
);
};
run("/nowhere/gitdir", "/nowhere/worktree");
run(shadow_dir.to_str().unwrap(), wt.to_str().unwrap());
let s = Shadow::new(&shadow_dir, "s02");
s.ensure(&wt, &[]).unwrap();
git(
&s.gitdir,
&wt,
&["checkout", "-q", "--orphan", "nothing-yet"],
)
.unwrap();
std::fs::write(wt.join("in-flight.rs"), "fn later() {}\n").unwrap();
turn_snapshot(&s, &wt);
assert_eq!(
s.turns(&wt).unwrap(),
Some(1),
"an unborn HEAD is still a tree worth photographing"
);
}
#[test]
fn a_turn_snapshot_is_not_the_agents_stranded_work() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
s.preflight(&wt).expect("a plain session harvests");
let (unkept_before, _) = s.unkept(&wt).unwrap();
std::fs::write(wt.join("in-flight.rs"), "fn later() {}\n").unwrap();
turn_snapshot(&s, &wt);
s.preflight(&wt)
.expect("a snapshot is not a commit the harvest would drop");
assert_eq!(
s.checkpoints(&wt).unwrap().unreachable,
0,
"and `log` does not warn about it every time"
);
let (unkept_after, _) = s.unkept(&wt).unwrap();
assert_eq!(
unkept_after, unkept_before,
"but it is not counted as the agent's own unharvested commits"
);
assert_eq!(
s.turns(&wt).unwrap(),
Some(1),
"it is counted as what it is, and separately"
);
}
#[test]
fn checkpoints_are_numbered_from_the_oldest_and_never_renumber() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
checkpoint(&s, &wt, "two.rs", "fn two() {}\n", "Add two");
let before: Vec<usize> = s
.checkpoints(&wt)
.unwrap()
.commits
.iter()
.map(|c| c.number)
.collect();
let first_subject = s.checkpoints(&wt).unwrap().commits[0].subject.clone();
checkpoint(&s, &wt, "three.rs", "fn three() {}\n", "Add three");
let after = s.checkpoints(&wt).unwrap().commits;
assert_eq!(before, vec![1, 2], "two commits, numbered from the oldest");
assert_eq!(
after.iter().map(|c| c.number).collect::<Vec<_>>(),
vec![1, 2, 3],
"a third appends rather than renumbering"
);
assert_eq!(
after[0].subject, first_subject,
"number 1 is still the same commit it was"
);
assert_eq!(after[0].subject, "Add one");
assert_eq!(after[2].subject, "Add three");
}
#[test]
fn the_seed_is_not_one_of_the_agents_checkpoints() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
assert!(
s.checkpoints(&wt).unwrap().commits.is_empty(),
"a sandbox that has committed nothing has nothing to show"
);
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
let one = s.checkpoints(&wt).unwrap().commits;
assert_eq!(
one.len(),
1,
"the agent's commit, and not the seed: {one:?}"
);
}
#[test]
fn checkpoints_say_which_have_already_been_handed_over() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
let handed = checkpoint(&s, &wt, "two.rs", "fn two() {}\n", "Add two");
checkpoint(&s, &wt, "three.rs", "fn three() {}\n", "Add three");
assert!(
s.checkpoints(&wt)
.unwrap()
.commits
.iter()
.all(|c| !c.landed),
"nothing has been handed over yet"
);
std::fs::write(&s.landed_record, format!("{handed}\n")).unwrap();
let after = s.checkpoints(&wt).unwrap().commits;
assert_eq!(
after.iter().map(|c| c.landed).collect::<Vec<_>>(),
vec![true, true, false],
"the line falls after what the last harvest took: {after:?}"
);
}
#[test]
fn a_checkpoint_reports_what_it_touched() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join("a.rs"), "one\ntwo\nthree\n").unwrap();
std::fs::write(wt.join("b.rs"), "one\n").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(
&s.gitdir,
&wt,
&["commit", "-q", "--no-verify", "-m", "Add two files"],
)
.unwrap();
std::fs::write(wt.join("a.rs"), "one\nchanged\nthree\nfour\n").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(
&s.gitdir,
&wt,
&["commit", "-q", "--no-verify", "-m", "Change a"],
)
.unwrap();
let all = s.checkpoints(&wt).unwrap().commits;
let touched = |c: &Checkpoint| {
let t = c.touched.clone().expect("not a merge");
(t.files, t.added, t.removed, t.uncounted)
};
assert_eq!(touched(&all[0]), (2, 4, 0, 0));
assert_eq!(touched(&all[1]), (1, 2, 1, 0));
}
#[test]
fn a_replaced_object_cannot_change_what_the_log_says() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let seed = s.seed().unwrap();
let real = checkpoint(
&s,
&wt,
"creds.rs",
"const KEY: &str = \"…\";\n",
"real work",
);
let forged = Command::new("git")
.arg("--git-dir")
.arg(&s.gitdir)
.args([
"commit-tree",
&format!("{seed}^{{tree}}"),
"-p",
&seed,
"-m",
"tidy whitespace",
])
.output()
.unwrap();
let forged = String::from_utf8_lossy(&forged.stdout).trim().to_string();
Command::new("git")
.arg("--git-dir")
.arg(&s.gitdir)
.args(["replace", "-f", &real, &forged])
.output()
.unwrap();
let commits = s.checkpoints(&wt).unwrap().commits;
assert_eq!(commits.len(), 1);
assert_eq!(
commits[0].subject, "real work",
"the log reports the commit, not what the agent replaced it with"
);
assert_eq!(
commits[0].touched.as_ref().map(|t| t.files),
Some(1),
"and what it really touched: {commits:?}"
);
}
#[test]
fn a_graft_file_cannot_hide_commits_from_the_log() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let seed = s.seed().unwrap();
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
checkpoint(&s, &wt, "two.rs", "fn two() {}\n", "Add two");
let head = checkpoint(&s, &wt, "three.rs", "fn three() {}\n", "Add three");
std::fs::create_dir_all(s.gitdir.join("info")).unwrap();
std::fs::write(s.gitdir.join("info/grafts"), format!("{head} {seed}\n")).unwrap();
let commits = s.checkpoints(&wt).unwrap().commits;
assert_eq!(
commits
.iter()
.map(|c| c.subject.as_str())
.collect::<Vec<_>>(),
vec!["Add one", "Add two", "Add three"],
"every checkpoint is still there: {commits:?}"
);
}
#[test]
fn a_merge_says_so_rather_than_reporting_that_it_touched_nothing() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let seed = s.seed().unwrap();
checkpoint(&s, &wt, "main.rs", "fn main() {}\n", "On the branch");
git(&s.gitdir, &wt, &["checkout", "-q", "-b", "side", &seed]).unwrap();
checkpoint(&s, &wt, "side.rs", "fn side() {}\n", "On the side");
git(&s.gitdir, &wt, &["checkout", "-q", "-"]).unwrap();
git(
&s.gitdir,
&wt,
&["merge", "-q", "--no-ff", "side", "-m", "Merge the side"],
)
.unwrap();
let commits = s.checkpoints(&wt).unwrap().commits;
let merge = commits
.iter()
.find(|c| c.subject == "Merge the side")
.unwrap_or_else(|| panic!("the merge is a checkpoint too: {commits:?}"));
assert_eq!(merge.touched, None, "not measured, and not zero: {merge:?}");
assert!(
commits.iter().any(|c| c.touched.is_some()),
"the ordinary commits are still measured"
);
}
#[test]
fn a_merge_does_not_renumber_the_checkpoints_already_listed() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let seed = s.seed().unwrap();
let at = |when: &str, file: &str, subject: &str| {
std::fs::write(wt.join(file), format!("// {subject}\n")).unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
let out = Command::new("git")
.envs(GUEST_ENV)
.arg("--git-dir")
.arg(&s.gitdir)
.arg("--work-tree")
.arg(&wt)
.env("GIT_AUTHOR_DATE", when)
.env("GIT_COMMITTER_DATE", when)
.args(["commit", "-q", "--no-verify", "-m", subject])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
};
at("2026-08-20T11:00:00", "a.rs", "A");
at("2026-08-20T13:00:00", "b.rs", "B");
let before = s.checkpoints(&wt).unwrap().commits;
git(&s.gitdir, &wt, &["checkout", "-q", "-b", "side", &seed]).unwrap();
at("2026-08-20T12:00:00", "s.rs", "S, older than B");
git(&s.gitdir, &wt, &["checkout", "-q", "-"]).unwrap();
git(
&s.gitdir,
&wt,
&["merge", "-q", "--no-ff", "side", "-m", "M"],
)
.unwrap();
let after = s.checkpoints(&wt).unwrap().commits;
for was in &before {
let now = after
.iter()
.find(|c| c.id == was.id)
.unwrap_or_else(|| panic!("{} left the list entirely", was.subject));
assert_eq!(
now.number, was.number,
"{} was {} and is now {} — a selection typed before the merge would \
land a different commit",
was.subject, was.number, now.number
);
}
}
#[test]
fn a_replay_point_the_history_lost_is_reported_rather_than_read_as_new_work() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let seed = s.seed().unwrap();
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
let handed = checkpoint(&s, &wt, "two.rs", "fn two() {}\n", "Add two");
std::fs::write(&s.landed_record, format!("{handed}\n")).unwrap();
assert!(
!s.checkpoints(&wt).unwrap().replay_point_lost,
"nothing is wrong yet"
);
git(&s.gitdir, &wt, &["reset", "-q", "--hard", &seed]).unwrap();
checkpoint(&s, &wt, "other.rs", "fn other() {}\n", "Different work");
let read = s.checkpoints(&wt).unwrap();
assert!(
read.replay_point_lost,
"omh cannot tell what the branch already has: {read:?}"
);
assert!(
read.commits.iter().all(|c| !c.landed),
"and marks nothing as handed over on a guess"
);
}
#[test]
fn commits_this_read_cannot_reach_are_counted_and_said() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
assert_eq!(s.checkpoints(&wt).unwrap().unreachable, 0);
git(&s.gitdir, &wt, &["checkout", "-q", "-b", "spike"]).unwrap();
checkpoint(&s, &wt, "spike.rs", "fn spike() {}\n", "A spike");
checkpoint(&s, &wt, "spike2.rs", "fn more() {}\n", "More of it");
git(&s.gitdir, &wt, &["checkout", "-q", "-"]).unwrap();
let read = s.checkpoints(&wt).unwrap();
assert_eq!(
read.unreachable, 2,
"two commits are on no branch this read follows: {read:?}"
);
assert_eq!(read.commits.len(), 1, "and the list cannot show them");
}
#[test]
fn a_file_git_would_not_count_is_not_counted_as_nothing() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join("blob.bin"), [0u8, 1, 2, 0, 255]).unwrap();
std::fs::write(wt.join("text.rs"), "one\ntwo\n").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(
&s.gitdir,
&wt,
&["commit", "-q", "--no-verify", "-m", "Both kinds"],
)
.unwrap();
let touched = s.checkpoints(&wt).unwrap().commits[0]
.touched
.clone()
.expect("not a merge");
assert_eq!(
(touched.files, touched.added, touched.uncounted),
(2, 2, 1),
"both files counted, the text lines counted, the blob's not invented: {touched:?}"
);
}
#[test]
fn a_checkpoint_dated_in_the_future_reads_as_just_now_rather_than_failing() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
checkpoint(&s, &wt, "now.rs", "fn now() {}\n", "Made now");
std::fs::write(wt.join("later.rs"), "fn later() {}\n").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
let out = Command::new("git")
.envs(GUEST_ENV)
.arg("--git-dir")
.arg(&s.gitdir)
.arg("--work-tree")
.arg(&wt)
.env("GIT_COMMITTER_DATE", "2099-01-01T00:00:00")
.args(["commit", "-q", "--no-verify", "-m", "From the future"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
std::fs::write(wt.join("replayed.rs"), "fn replayed() {}\n").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
let out = Command::new("git")
.envs(GUEST_ENV)
.arg("--git-dir")
.arg(&s.gitdir)
.arg("--work-tree")
.arg(&wt)
.env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00")
.args([
"commit",
"-q",
"--no-verify",
"-m",
"Written long ago, committed now",
])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let commits = s.checkpoints(&wt).unwrap().commits;
assert_eq!(
commits[0].age.map(|age| age < 300),
Some(true),
"a commit made now is dated now: {:?}",
commits[0]
);
assert_eq!(
commits[1].age,
Some(0),
"and one dated in the future reads as just now: {:?}",
commits[1]
);
assert_eq!(
commits[2].age.map(|age| age < 300),
Some(true),
"and one written years ago but committed now is dated by the commit: {:?}",
commits[2]
);
}
#[test]
fn a_checkpoint_number_shows_that_checkpoints_own_patch() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
let second = checkpoint(&s, &wt, "two.rs", "fn two() {}\n", "Add two");
let patch = s.show(&wt, 2, crate::session::What::Patch).unwrap();
assert!(
patch.contains("+fn two() {}") && !patch.contains("+fn one() {}"),
"checkpoint 2 is the second commit and nothing else: {patch}"
);
let theirs = Command::new("git")
.arg("--git-dir")
.arg(&s.gitdir)
.args(["show", "-p", &second])
.output()
.unwrap();
assert_eq!(
patch,
String::from_utf8_lossy(&theirs.stdout),
"the same commit git would show for that id"
);
}
#[test]
fn a_subject_the_agent_wrote_cannot_repaint_a_checkpoint_review() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
checkpoint(
&s,
&wt,
"one.rs",
"fn one() {}\n",
"Fix \u{1b}[2K\rnothing at all",
);
let body = s.show(&wt, 1, crate::session::What::Summary).unwrap();
let report = crate::report::Diff {
label: "s01 checkpoint 1".into(),
session: "s01".into(),
checkpoint: Some(1),
base: "its parent".into(),
what: crate::session::What::Summary,
body,
};
use crate::out::Report;
let printed = report.human(&crate::out::Palette::plain());
assert!(
!printed.chars().any(|c| c.is_control() && c != '\n'),
"no control character survives into omh's own output: {printed:?}"
);
assert!(
printed.contains("nothing at all"),
"the words still arrive: {printed}"
);
assert!(
report.json()["summary"]
.as_str()
.is_some_and(|s| s.contains('\u{1b}')),
"and a program still gets what git said: {}",
report.json()
);
}
#[test]
fn a_merges_summary_and_its_patch_do_not_contradict_each_other() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let seed = s.seed().unwrap();
checkpoint(&s, &wt, "main.rs", "fn main() {}\n", "On the branch");
git(&s.gitdir, &wt, &["checkout", "-q", "-b", "side", &seed]).unwrap();
checkpoint(&s, &wt, "side.rs", "fn side() {}\n", "On the side");
git(&s.gitdir, &wt, &["checkout", "-q", "-"]).unwrap();
git(
&s.gitdir,
&wt,
&["merge", "-q", "--no-ff", "side", "-m", "Merge the side"],
)
.unwrap();
let merge = s
.checkpoints(&wt)
.unwrap()
.commits
.iter()
.find(|c| c.subject == "Merge the side")
.map(|c| c.number)
.expect("the merge is a checkpoint");
let summary = s.show(&wt, merge, crate::session::What::Summary).unwrap();
let patch = s.show(&wt, merge, crate::session::What::Patch).unwrap();
assert!(
summary.contains("side.rs"),
"the summary names what the merge brought in: {summary}"
);
assert!(
patch.contains("side.rs") && patch.contains("+fn side() {}"),
"and the patch shows it, rather than being empty: {patch}"
);
}
#[test]
fn the_pager_omh_names_last_is_the_users_own() {
let ours = NEUTRALISED
.iter()
.position(|kv| kv.starts_with("core.pager="))
.expect("the sandbox's pager is pinned");
assert_eq!(
NEUTRALISED[ours], "core.pager=cat",
"pinned to something that cannot run anything"
);
let pinned: Vec<String> = NEUTRALISED
.iter()
.flat_map(|kv| ["-c".to_string(), kv.to_string()])
.collect();
let mine = format!("core.pager={}", user_pager(std::path::Path::new(".")));
let full: Vec<String> = pinned
.iter()
.cloned()
.chain(["-c".to_string(), mine.clone()])
.collect();
let at = |needle: &str| full.iter().position(|a| a == needle);
assert!(
at(&mine) > at("core.pager=cat"),
"the user's pager comes after the sandbox's, or the sandbox's wins: {full:?}"
);
assert!(!mine.ends_with('='), "and it names something: {mine}");
}
#[test]
fn a_checkpoint_read_is_guarded_like_every_other_read() {
let args = show_args(crate::session::What::Patch, "abc123", "never");
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let guarded = guarded(&args);
assert_eq!(guarded.first().map(String::as_str), Some("show"));
assert!(
guarded.contains(&"--no-textconv".to_string())
&& guarded.contains(&"--no-ext-diff".to_string()),
"the flags a read has to carry, after the verb: {guarded:?}"
);
assert!(
guarded.contains(&"--first-parent".to_string()),
"so a merge's patch is not empty: {guarded:?}"
);
assert!(
guarded.contains(&"--color=never".to_string()),
"and colour is omh's decision, not git's guess: {guarded:?}"
);
}
#[test]
fn a_checkpoint_number_the_session_does_not_have_is_refused() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let empty = s
.show(&wt, 1, crate::session::What::Summary)
.expect_err("nothing has been committed here");
assert!(
empty.to_string().contains("not committed anything"),
"an empty sandbox says so rather than naming a range it does not have: {empty}"
);
checkpoint(&s, &wt, "one.rs", "fn one() {}\n", "Add one");
checkpoint(&s, &wt, "two.rs", "fn two() {}\n", "Add two");
let err = s
.show(&wt, 9, crate::session::What::Summary)
.expect_err("there is no checkpoint 9");
assert!(
err.to_string().contains('9') && err.to_string().contains("1 to 2"),
"the refusal names both the number and the range: {err}"
);
assert!(
s.show(&wt, 0, crate::session::What::Summary).is_err(),
"the numbers start at 1"
);
}
#[test]
fn an_option_listing_is_read_the_way_git_writes_it() {
let help = "usage: git cherry-pick [--edit] [-n] [-m <parent-number>]\n\
\x20 --quit end revert or cherry-pick sequence\n\
\x20 -n, --no-commit don't automatically commit\n\
\x20 -e, --[no-]edit edit the commit message\n\
\x20 --[no-]ff allow fast-forward\n\
\x20 --empty (stop|drop|keep)\n\
\x20 how to handle commits that become empty\n\
\x20 -S, --[no-]gpg-sign[=<key-id>]\n\
\x20 GPG sign commit\n\
\x20 --[no-]keep-redundant-commits\n\
\x20 deprecated: use --empty=keep instead\n\
\x20 --verify opposite of --no-rebase-merges\n";
let found = options_in(help);
let has = |o: &str| found.contains(o);
assert!(has("--empty"), "the option omh actually asks about");
assert!(has("--no-commit") && has("-n"));
assert!(has("--edit") && has("-e"), "and behind a negation as well");
assert!(has("--ff") && has("--no-ff"));
assert!(has("--gpg-sign"), "…and one that also takes a value");
assert!(has("--keep-redundant-commits"));
assert!(
!has("--empty=keep"),
"a description that *mentions* an option does not declare one — this line \
says `deprecated: use --empty=keep instead`"
);
assert!(!has("--no-such-option"), "and absence is absence");
assert!(has("--verify"), "the declaration is read");
assert!(
!has("--no-rebase-merges"),
"and its description is not — that option is not declared here"
);
assert!(
options_in("no options here at all\n").is_empty(),
"text that is not a listing declares nothing, which is not the same as an \
option being absent"
);
}
#[test]
fn git_answers_for_the_verb_it_was_asked_about() {
assert!(git_supports("merge", "--ff-only").unwrap());
assert!(
!git_supports("cherry-pick", "--ff-only").unwrap(),
"cherry-pick has no --ff-only, and asking it must not answer for merge"
);
assert!(git_supports("cherry-pick", "--empty").unwrap());
assert!(
git_supports("cherry-pick", "--empty=").unwrap(),
"the spelling every comment in this tree uses is the same question"
);
assert!(!git_supports("cherry-pick", "--no-such-option-ever").unwrap());
}
#[test]
fn a_git_that_cannot_answer_is_not_a_git_that_said_no() {
let err = git_supports("not-a-git-verb-at-all", "--empty")
.expect_err("nothing was listed, so nothing can be concluded");
assert!(
err.to_string().contains("listed no options"),
"the refusal says what happened: {err}"
);
}
#[test]
fn a_selection_names_checkpoints_in_the_order_it_lists_them() {
assert_eq!(chosen("1,3-4", 4).unwrap(), vec![1, 3, 4]);
assert_eq!(chosen("2", 4).unwrap(), vec![2], "one is a selection");
assert_eq!(chosen("1-4", 4).unwrap(), vec![1, 2, 3, 4], "a whole range");
assert_eq!(chosen("3,1", 4).unwrap(), vec![3, 1]);
assert_eq!(chosen(" 1, 3 - 4 ", 4).unwrap(), vec![1, 3, 4]);
}
#[test]
fn a_selection_that_cannot_mean_what_it_says_is_refused() {
let refused = |spec: &str, because: &str| {
let err = chosen(spec, 4)
.map(|got| format!("{got:?}"))
.expect_err(&format!("`{spec}` is not a selection: {because}"));
err.to_string()
};
assert!(
refused("", "nothing was named").contains("no checkpoints"),
"an empty selection is a question, not everything"
);
assert!(refused("0", "the numbers start at 1").contains('0'));
assert!(
refused("9", "there are four").contains("1 to 4"),
"the refusal names the range the list actually has"
);
assert!(refused("2-9", "the range runs past the end").contains("1 to 4"));
assert!(refused("two", "not a number").contains("two"));
assert!(refused("1,,2", "an empty element").contains("1,,2"));
assert!(
refused("4-2", "backwards").contains("4-2"),
"a descending range is ambiguous — reversing it is a guess"
);
assert!(
refused("1,1", "twice").contains('1'),
"a commit picked twice applies twice, which is not what the list showed"
);
assert!(refused("-", "no numbers at all").contains('-'));
}
#[test]
fn a_selection_is_bounded_by_what_the_session_has() {
assert!(chosen("1", 1).is_ok());
assert!(
chosen("2", 1).is_err(),
"one checkpoint means one valid number"
);
assert!(
chosen("1", 0).is_err(),
"and an empty sandbox has none at all"
);
}
#[test]
fn the_shadow_holds_no_commit_from_the_checkout() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let host_commit = head_of(&checkout);
let reachable = Command::new("git")
.arg("--git-dir")
.arg(&s.gitdir)
.args(["cat-file", "-e", &host_commit])
.output()
.unwrap();
assert!(
!reachable.status.success(),
"the host's history must not be reachable from the sandbox's repo"
);
let count = git(&s.gitdir, &wt, &["rev-list", "--all", "--count"]).unwrap();
assert_eq!(count.trim(), "1", "the seed, and nothing else");
let remotes = git(&s.gitdir, &wt, &["remote"]).unwrap();
assert!(remotes.trim().is_empty(), "nowhere to push: {remotes:?}");
}
#[test]
fn the_seed_is_recorded_where_the_sandbox_cannot_reach_it() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let seed = std::fs::read_to_string(&s.seed_record).unwrap();
assert!(!seed.is_empty(), "a seed has to be recorded");
assert!(
!s.seed_record.starts_with(&s.gitdir),
"the record must not sit inside the directory the agent can write"
);
let _ = git(&s.gitdir, &wt, &["tag", "-d", "session-start"]);
assert_eq!(
std::fs::read_to_string(&s.seed_record).unwrap(),
seed,
"the seed survives the sandbox"
);
}
#[test]
fn a_carried_file_is_not_something_the_shadow_tracks() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[".env".to_string()]).unwrap();
let tracked = git(&s.gitdir, &wt, &["ls-files"]).unwrap();
assert!(
!tracked.contains(".env"),
"carried, not committed: {tracked}"
);
let status = git(&s.gitdir, &wt, &["status", "--porcelain"]).unwrap();
assert!(
status.trim().is_empty(),
"a session opens on a clean tree or the agent starts by tidying: {status:?}"
);
}
#[test]
fn the_shadow_refuses_to_push() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let hook = s.gitdir.join("hooks/pre-push");
assert!(hook.exists(), "a pre-push hook has to be installed");
let out = Command::new("sh").arg(&hook).output().unwrap();
assert!(!out.status.success(), "the hook must refuse");
let said = String::from_utf8_lossy(&out.stderr);
assert!(
said.contains("omh s commit"),
"a refusal has to say where work actually leaves: {said}"
);
assert!(
!said.contains("syntax error") && !said.contains("unexpected"),
"the hook must run, not merely fail to parse: {said}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&hook).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0o111, "git ignores a non-executable hook");
}
}
#[test]
fn the_pointer_file_alone_resolves_to_the_worktree_it_sits_in() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join(".git"), format!("gitdir: {}\n", s.gitdir.display())).unwrap();
let out = Command::new("git")
.current_dir(&wt)
.args(["rev-parse", "--show-toplevel"])
.output()
.unwrap();
assert!(
out.status.success(),
"git must work through the pointer alone: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim(),
std::fs::canonicalize(&wt).unwrap().to_string_lossy(),
"the worktree is where the pointer sits, not somewhere recorded on the host"
);
}
#[test]
fn a_half_built_shadow_is_never_mistaken_for_a_finished_one() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
Command::new("git")
.args(["init", "-q", "--bare"])
.arg(&s.gitdir)
.output()
.unwrap();
assert!(!s.seed_record.exists(), "it never got as far as the seed");
s.ensure(&wt, &[".env".to_string()]).unwrap();
assert!(s.seed_record.exists(), "the seed has to be recorded");
assert_eq!(
git(&s.gitdir, &wt, &["rev-list", "--all", "--count"])
.unwrap()
.trim(),
"1",
"a finished shadow has its seed commit"
);
let status = git(&s.gitdir, &wt, &["status", "--porcelain"]).unwrap();
assert!(!status.contains(".env"), "and its exclude list: {status:?}");
}
#[test]
fn a_hook_the_sandbox_plants_does_not_run_on_the_host() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let planted = shadow_dir.join("planted");
let hooks = shadow_dir.join("evil-hooks");
std::fs::create_dir_all(&hooks).unwrap();
let hook = hooks.join("post-checkout");
std::fs::write(&hook, format!("#!/bin/sh\ntouch {}\n", planted.display())).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
Command::new("git")
.arg("--git-dir")
.arg(&s.gitdir)
.args(["config", "core.hooksPath"])
.arg(&hooks)
.output()
.unwrap();
let _ = git(&s.gitdir, &wt, &["checkout", "-q", "--", "."]);
assert!(
!planted.exists(),
"the sandbox's own config decided what ran on the host"
);
}
#[test]
fn the_seed_commit_carries_the_arrangement_to_git_log() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let said = git(&s.gitdir, &wt, &["log", "-1", "--format=%B"]).unwrap();
assert!(
said.contains(ARRANGEMENT),
"the agent reads this on `git log` and nowhere else: {said}"
);
}
#[test]
fn a_harvest_lands_the_agents_commits_under_the_sandboxs_name() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
git(
&s.gitdir,
&wt,
&["config", "user.name", "Nathanael Cherrier"],
)
.unwrap();
git(
&s.gitdir,
&wt,
&["config", "user.email", "nathanael@mindsers.it"],
)
.unwrap();
std::fs::write(wt.join("f.txt"), "one\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "Fix the tap guard"]).unwrap();
std::fs::write(wt.join("helper.rs"), "fn h() {}").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", "Extract helper"]).unwrap();
std::fs::write(wt.join("tail.rs"), "fn t() {}").unwrap();
let landed = s
.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap();
let log = git_in(&checkout, &["log", "--format=%an|%s", "main..omh/s01"]).unwrap();
let lines: Vec<&str> = log.lines().collect();
assert_eq!(landed, lines.len(), "reported count is what landed: {log}");
assert!(
lines.iter().any(|l| l.ends_with("|Extract helper")),
"the agent's own messages are the point: {log}"
);
assert!(
lines.iter().all(|l| l.starts_with(AUTHOR_NAME)),
"the sandbox says who it is on the way in, whatever it claimed: {log}"
);
assert!(
git_in(&checkout, &["show", "omh/s01:tail.rs"]).is_ok(),
"work the agent never checkpointed is still its work"
);
assert!(
git_in(
&checkout,
&[
"rev-parse",
"--verify",
"-q",
"refs/omh/s01-scratch/harvest"
]
)
.is_err(),
"the fetched ref goes once the branch has the work, or the \
pre-curation objects stay reachable in the user's repository"
);
}
#[test]
fn a_harvest_refuses_a_commit_holding_something_you_carried_in() {
for (name, plant) in [
("force-added", "add-f"),
("copied under another name", "copy"),
("pasted into source", "inline"),
("written into a commit message", "message"),
] {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[".env".to_string()]).unwrap();
std::fs::write(checkout.join(".env"), "API_TOKEN=ghp_abc123def456\n").unwrap();
std::fs::write(wt.join(".env"), "API_TOKEN=ghp_abc123def456\n").unwrap();
match plant {
"add-f" => {
git(&s.gitdir, &wt, &["add", "-f", ".env"]).unwrap();
}
"copy" => {
std::fs::copy(wt.join(".env"), wt.join("config.bak")).unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
}
"inline" => {
std::fs::write(wt.join("k.rs"), "const K = \"API_TOKEN=ghp_abc123def456\";")
.unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
}
_ => {
std::fs::write(wt.join("note.rs"), "fn n() {}").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
}
}
let subject = if plant == "message" {
"note: API_TOKEN=ghp_abc123def456"
} else {
"Save config"
};
git(&s.gitdir, &wt, &["commit", "-qm", subject]).unwrap();
let err = s
.harvest(&checkout, &wt, "omh/s01", &[".env".to_string()], Keep::All)
.unwrap_err()
.to_string();
assert!(
err.contains("carried"),
"{name}: a harvest must refuse it, not carry it: {err}"
);
assert!(
git_in(&checkout, &["log", "--oneline", "main..omh/s01"])
.unwrap()
.trim()
.is_empty(),
"{name}: and the branch must be untouched"
);
}
}
#[test]
fn a_secret_that_looks_like_a_pattern_is_still_caught() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[".env".to_string()]).unwrap();
let secret = "KEY=ab*cd/ef12345==";
std::fs::write(checkout.join(".env"), format!("{secret}\n")).unwrap();
std::fs::write(wt.join(".env"), format!("{secret}\n")).unwrap();
std::fs::write(wt.join("note.rs"), "fn n() {}").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(
&s.gitdir,
&wt,
&["commit", "-qm", &format!("ship with {secret}")],
)
.unwrap();
let err = s
.harvest(&checkout, &wt, "omh/s01", &[".env".to_string()], Keep::All)
.unwrap_err()
.to_string();
assert!(
err.contains("carried"),
"a secret that is its own regex still has to be refused: {err}"
);
assert!(
git_in(&checkout, &["log", "--oneline", "main..omh/s01"])
.unwrap()
.trim()
.is_empty(),
"and the branch must be untouched"
);
}
#[test]
fn a_carried_line_that_is_not_a_regex_does_not_break_the_harvest() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[".env".to_string()]).unwrap();
let carried = "SECRET_TOKEN=a[bcdefgh";
std::fs::write(checkout.join(".env"), format!("{carried}\n")).unwrap();
std::fs::write(wt.join(".env"), format!("{carried}\n")).unwrap();
std::fs::write(wt.join("work.rs"), "fn work() {}").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", "Ordinary work"]).unwrap();
let landed = s
.harvest(&checkout, &wt, "omh/s01", &[".env".to_string()], Keep::All)
.expect("a carried line nobody committed is not a reason to refuse");
assert_eq!(landed, 1, "the agent's commit still has to land");
}
#[test]
fn harvesting_twice_keeps_nothing_the_second_time() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join("f.txt"), "base\nadded\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "Add a line"]).unwrap();
std::fs::write(wt.join("f.txt"), "base\nadded\nmore\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "Add another"]).unwrap();
assert_eq!(
s.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap(),
2
);
let tip = git_in(&checkout, &["rev-parse", "omh/s01"]).unwrap();
assert_eq!(
s.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap(),
0,
"there is nothing new to keep, and saying so is the whole job"
);
assert_eq!(
git_in(&checkout, &["rev-parse", "omh/s01"]).unwrap(),
tip,
"and a branch with nothing to add must not move"
);
}
#[test]
fn a_second_harvest_takes_only_what_is_new() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join("f.txt"), "base\nfirst\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "The first round"]).unwrap();
assert_eq!(
s.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap(),
1
);
std::fs::write(wt.join("f.txt"), "base\nfirst\nsecond\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "The second round"]).unwrap();
assert_eq!(
s.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap(),
1,
"only the round that has not landed yet"
);
let log = git_in(&checkout, &["log", "--format=%s", "main..omh/s01"]).unwrap();
let subjects: Vec<&str> = log.lines().collect();
assert_eq!(
subjects,
vec!["The second round", "The first round"],
"each round lands once, in order: {log}"
);
}
#[test]
fn a_refusal_cannot_be_repainted_by_the_subject_it_quotes() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[".env".to_string()]).unwrap();
let secret = "API_TOKEN=ghp_abc123def456";
std::fs::write(checkout.join(".env"), format!("{secret}\n")).unwrap();
std::fs::write(wt.join(".env"), format!("{secret}\n")).unwrap();
std::fs::write(wt.join("note.rs"), "fn n() {}").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(
&s.gitdir,
&wt,
&[
"commit",
"-qm",
&format!(
"note: {secret}{}[2K\rcommitted to main, nothing carried",
'\u{1b}'
),
],
)
.unwrap();
let err = s
.harvest(&checkout, &wt, "omh/s01", &[".env".to_string()], Keep::All)
.unwrap_err()
.to_string();
assert!(err.contains("carried"), "it still has to refuse: {err}");
assert!(
!err.contains('\u{1b}'),
"and it may not hand the terminal to the subject it quotes: {err:?}"
);
}
#[test]
fn a_converter_the_sandbox_named_does_not_run_on_the_host() {
let (d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let marker = d.path().join("THE-CONVERTER-RAN");
let conv = d.path().join("conv.sh");
std::fs::write(
&conv,
format!("#!/bin/sh\ntouch {}\ncat \"$1\"\n", marker.display()),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&conv, std::fs::Permissions::from_mode(0o755)).unwrap();
}
git(
&s.gitdir,
&wt,
&["config", "diff.pwn.textconv", conv.to_str().unwrap()],
)
.unwrap();
std::fs::write(wt.join(".gitattributes"), "* diff=pwn\n").unwrap();
std::fs::write(wt.join("f.txt"), "SECRET=abc123def456\n").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", "a checkpoint"]).unwrap();
let _ = git(
&s.gitdir,
&wt,
&["log", "--oneline", "-S", "SECRET=abc123def456", "HEAD"],
);
assert!(
!marker.exists(),
"a driver the sandbox named ran on the host, as the user"
);
}
#[test]
fn a_key_with_two_values_does_not_brick_the_next_launch() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
git(&s.gitdir, &wt, &["config", "--add", "core.gitproxy", "one"]).unwrap();
git(&s.gitdir, &wt, &["config", "--add", "core.gitproxy", "two"]).unwrap();
assert_eq!(
git(
&s.gitdir,
&wt,
&["config", "--list", "--local", "--name-only"]
)
.unwrap()
.lines()
.filter(|k| k.trim() == "core.gitproxy")
.count(),
2,
"the precondition is that git lists the key once per value"
);
s.ensure(&wt, &[])
.expect("a key with two values is still just a key to drop");
let config = std::fs::read_to_string(s.gitdir.join("config")).unwrap();
assert!(!config.contains("gitproxy"), "and it is gone:\n{config}");
}
#[test]
fn refreshing_keeps_what_git_records_about_the_repository() {
let (d, wt, shadow_dir) = fixture();
let pristine = d.path().join("pristine.git");
Command::new("git")
.args(["init", "-q", "--bare", "--template="])
.arg(&pristine)
.output()
.unwrap();
let listed = Command::new("git")
.arg("--git-dir")
.arg(&pristine)
.args(["config", "--list", "--local", "--name-only"])
.output()
.unwrap();
let expected: Vec<String> = String::from_utf8_lossy(&listed.stdout)
.lines()
.map(str::trim)
.filter(|k| !k.is_empty() && *k != "core.bare")
.map(str::to_string)
.collect();
assert!(
!expected.is_empty(),
"a fresh `git init` records something, or this test is vacuous"
);
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
s.ensure(&wt, &[]).unwrap();
let kept = git(
&s.gitdir,
&wt,
&["config", "--list", "--local", "--name-only"],
)
.unwrap();
for key in expected {
assert!(
kept.lines().any(|k| k.trim() == key),
"{key} is something git recorded about this repository and omh \
dropped it. kept:\n{kept}"
);
}
}
#[test]
fn the_sandboxs_config_is_omhs_again_at_every_launch() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
for (key, value) in [
("diff.pwn.textconv", "/bin/sh"),
("core.sshCommand", "/bin/sh"),
("core.hooksPath", "/tmp/mine"),
] {
git(&s.gitdir, &wt, &["config", key, value]).unwrap();
}
std::fs::write(wt.join("agent.rs"), "fn main() {}").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", "a checkpoint"]).unwrap();
let checkpoint = git(&s.gitdir, &wt, &["rev-parse", "HEAD"]).unwrap();
s.ensure(&wt, &[]).unwrap();
let config = std::fs::read_to_string(s.gitdir.join("config")).unwrap();
for key in ["textconv", "sshCommand", "hooksPath"] {
assert!(
!config.contains(key),
"{key} survived a relaunch:\n{config}"
);
}
assert_eq!(
git(&s.gitdir, &wt, &["config", "user.email"])
.unwrap()
.trim(),
AUTHOR_EMAIL,
"and the identity omh needs to commit at all is still there"
);
assert_eq!(
git(&s.gitdir, &wt, &["rev-parse", "HEAD"]).unwrap(),
checkpoint,
"and the agent's work is untouched"
);
}
#[test]
fn a_record_omh_cannot_read_is_not_a_session_that_never_landed() {
for (name, break_it) in [
("empty", 0usize),
#[cfg(unix)]
("unreadable", 1usize),
] {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join("f.txt"), "base\nfirst\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "The first round"]).unwrap();
s.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap();
let tip = git_in(&checkout, &["rev-parse", "omh/s01"]).unwrap();
match break_it {
0 => std::fs::write(&s.landed_record, "").unwrap(),
_ => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&s.landed_record,
std::fs::Permissions::from_mode(0o000),
)
.unwrap();
}
}
}
std::fs::write(wt.join("f.txt"), "base\nfirst\nsecond\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "The second round"]).unwrap();
let outcome = s.harvest(&checkout, &wt, "omh/s01", &[], Keep::All);
assert!(
outcome.is_err(),
"{name}: a record omh cannot read must not read as a session that \
never landed"
);
assert_eq!(
git_in(&checkout, &["rev-parse", "omh/s01"]).unwrap(),
tip,
"{name}: and the branch must not move on a refusal"
);
#[cfg(unix)]
if break_it == 1 {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&s.landed_record,
std::fs::Permissions::from_mode(0o644),
);
}
}
}
#[test]
fn reaping_takes_the_replay_point_with_it() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join("f.txt"), "base\nwork\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "Some work"]).unwrap();
s.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap();
assert!(
s.landed().unwrap().is_some(),
"the precondition is that a harvest recorded one"
);
s.reap();
assert!(
Shadow::new(&shadow_dir, "s01").landed().unwrap().is_none(),
"the next session to take this id must start from its own seed"
);
}
#[test]
fn a_sandbox_that_rewound_past_what_you_kept_is_refused() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let start = git(&s.gitdir, &wt, &["rev-parse", "HEAD"]).unwrap();
std::fs::write(wt.join("f.txt"), "base\nkept\n").unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", "Work that gets kept"]).unwrap();
s.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap();
git(&s.gitdir, &wt, &["reset", "-q", "--hard", start.trim()]).unwrap();
std::fs::write(wt.join("g.txt"), "different\n").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", "A different direction"]).unwrap();
let err = s
.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap_err()
.to_string();
assert!(
err.contains("omh s commit -m"),
"a refusal has to say what to do instead: {err}"
);
}
#[test]
fn a_carried_file_is_never_in_the_seed_the_harvest_fetches() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
std::fs::write(wt.join(".env"), "API_TOKEN=ghp_abc123def456\n").unwrap();
std::fs::write(wt.join("cert.pem"), "BEGIN-ghp_abc123def456-END\n").unwrap();
s.ensure(&wt, &[".env".to_string(), "cert.pem".to_string()])
.unwrap();
let tracked = git(&s.gitdir, &wt, &["ls-tree", "-r", "--name-only", "HEAD"]).unwrap();
let staged = git(&s.gitdir, &wt, &["ls-files"]).unwrap();
for probe in [&tracked, &staged] {
assert!(
!probe.contains(".env") && !probe.contains("cert.pem"),
"the seed must neither track nor stage a carried file: {probe}"
);
}
git_in(
&checkout,
&[
"-c",
"protocol.file.allow=always",
"fetch",
"-q",
&s.gitdir.to_string_lossy(),
"+HEAD:refs/omh/probe",
],
)
.unwrap();
let objects = git_in(&checkout, &["rev-list", "--objects", "refs/omh/probe"]).unwrap();
assert!(
!objects.trim().is_empty(),
"the probe enumerated no objects, so it proved nothing"
);
let mut read_a_blob = false;
for line in objects.lines() {
let oid = line.split_whitespace().next().unwrap_or_default();
let body = git_in(&checkout, &["cat-file", "-p", oid]).unwrap();
read_a_blob |= body.contains("base");
assert!(
!body.contains("ghp_abc123def456"),
"a fetch put the carried secret in the user's repository: {line}"
);
}
assert!(
read_a_blob,
"the enumeration never read file content, so a leak in one would \
not have been seen"
);
}
#[test]
fn a_selection_lands_those_commits_in_that_order() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
for m in ["one", "two", "three", "four"] {
std::fs::write(wt.join(format!("{m}.rs")), format!("fn {m}() {{}}\n")).unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", m]).unwrap();
}
let ids: Vec<String> = s
.checkpoints(&wt)
.unwrap()
.commits
.iter()
.map(|c| c.id.clone())
.collect();
let landed = s
.harvest(
&checkout,
&wt,
"omh/s01",
&[],
Keep::These(vec![ids[2].clone(), ids[0].clone()]),
)
.unwrap();
let log = git_in(&checkout, &["log", "--format=%s", "main..omh/s01"]).unwrap();
let on_branch: Vec<&str> = log.lines().collect();
assert_eq!(
on_branch,
vec!["one", "three"],
"newest first from `git log`, so `three` was applied first: {log}"
);
assert_eq!(landed, 2, "and the count is what arrived: {log}");
}
#[test]
fn a_selection_leaves_the_rest_where_they_were() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
for m in ["one", "two", "three", "four"] {
std::fs::write(wt.join(format!("{m}.rs")), format!("fn {m}() {{}}\n")).unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", m]).unwrap();
}
let ids: Vec<String> = s
.checkpoints(&wt)
.unwrap()
.commits
.iter()
.map(|c| c.id.clone())
.collect();
s.harvest(
&checkout,
&wt,
"omh/s01",
&[],
Keep::These(vec![ids[0].clone(), ids[1].clone()]),
)
.unwrap();
let log = git_in(&checkout, &["log", "--format=%s", "main..omh/s01"]).unwrap();
assert_eq!(log.lines().count(), 2, "two of the four: {log}");
assert!(
!log.contains("three") && !log.contains("four"),
"the ones not named are not on the branch: {log}"
);
}
#[test]
fn a_selection_does_not_sweep_up_work_the_user_could_not_have_named() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
for m in ["one", "two"] {
std::fs::write(wt.join(format!("{m}.rs")), format!("fn {m}() {{}}\n")).unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", m]).unwrap();
}
let ids: Vec<String> = s
.checkpoints(&wt)
.unwrap()
.commits
.iter()
.map(|c| c.id.clone())
.collect();
std::fs::write(wt.join("in-flight.rs"), "fn later() {}\n").unwrap();
s.harvest(
&checkout,
&wt,
"omh/s01",
&[],
Keep::These(vec![ids[0].clone()]),
)
.unwrap();
let log = git(&s.gitdir, &wt, &["log", "--format=%s"]).unwrap();
assert!(
!log.contains("Work in progress"),
"a selection made a commit the user never named: {log}"
);
let read = s.checkpoints(&wt).unwrap();
assert_eq!(
read.commits.len(),
2,
"still two checkpoints, not three: {read:?}"
);
assert!(
read.uncommitted > 0,
"and the tail is still where the next `--keep` can see it: {read:?}"
);
}
#[test]
fn a_second_keep_brings_what_the_first_selection_left() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
for m in ["one", "two", "three", "four"] {
std::fs::write(wt.join(format!("{m}.rs")), format!("fn {m}() {{}}\n")).unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", m]).unwrap();
}
let ids: Vec<String> = s
.checkpoints(&wt)
.unwrap()
.commits
.iter()
.map(|c| c.id.clone())
.collect();
s.harvest(
&checkout,
&wt,
"omh/s01",
&[],
Keep::These(vec![ids[0].clone(), ids[2].clone()]),
)
.unwrap();
let read = s.checkpoints(&wt).unwrap();
assert!(
read.commits.iter().filter(|c| c.landed).count() <= 1,
"only what was taken and everything before it may read as handed over: {:?}",
read.commits
);
let landed = s
.harvest(&checkout, &wt, "omh/s01", &[], Keep::All)
.unwrap();
let log = git_in(&checkout, &["log", "--format=%s", "main..omh/s01"]).unwrap();
for m in ["one", "two", "three", "four"] {
assert!(
log.contains(m),
"`{m}` never reached the branch across two harvests: {log}"
);
}
assert!(
landed >= 2,
"the second harvest brought the ones the first left: {log}"
);
}
#[test]
fn curating_drops_what_the_user_drops_and_says_so() {
let (d, wt, shadow_dir) = fixture();
let checkout = d.path().join("checkout");
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
for m in ["one", "two", "three"] {
std::fs::write(wt.join(format!("{m}.rs")), format!("fn {m}() {{}}\n")).unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
git(&s.gitdir, &wt, &["commit", "-qm", m]).unwrap();
}
let editor = d.path().join("drop-one.sh");
std::fs::write(&editor, "#!/bin/sh\nsed -i.bak '2d' \"$1\"\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&editor, std::fs::Permissions::from_mode(0o755)).unwrap();
}
git_in(
&checkout,
&["config", "sequence.editor", &editor.to_string_lossy()],
)
.unwrap();
let landed = s
.harvest(&checkout, &wt, "omh/s01", &[], Keep::Edit)
.unwrap();
let log = git_in(&checkout, &["log", "--format=%s", "main..omh/s01"]).unwrap();
let on_branch = log.lines().count();
assert_eq!(on_branch, 2, "one of the three was dropped: {log}");
assert_eq!(
landed, on_branch,
"the number reported is what landed, not what was offered: {log}"
);
}
#[test]
fn a_harvest_refuses_a_history_it_cannot_see_all_of() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let cp = |m: &str| {
std::fs::write(wt.join("f.txt"), format!("{m}\n")).unwrap();
git(&s.gitdir, &wt, &["commit", "-qam", m]).unwrap();
};
cp("first");
cp("second");
s.preflight(&wt).expect("a clean history harvests");
git(&s.gitdir, &wt, &["checkout", "-q", "HEAD~1"]).unwrap();
let err = s.preflight(&wt).unwrap_err().to_string();
assert!(err.contains("detached"), "{err}");
assert!(err.contains(&s.branch), "say how to put it back: {err}");
git(&s.gitdir, &wt, &["checkout", "-q", &s.branch]).unwrap();
git(&s.gitdir, &wt, &["branch", "aside", "HEAD"]).unwrap();
git(&s.gitdir, &wt, &["reset", "-q", "--hard", "HEAD~1"]).unwrap();
let err = s.preflight(&wt).unwrap_err().to_string();
assert!(
err.contains("no branch it is on can reach"),
"stranded commits are the ones no other check sees: {err}"
);
}
#[test]
fn a_harvest_refuses_a_repository_left_mid_rebase() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::create_dir_all(s.gitdir.join("rebase-merge")).unwrap();
let err = s.preflight(&wt).unwrap_err().to_string();
assert!(
err.contains("rebase-merge"),
"name what is in progress: {err}"
);
}
#[test]
fn the_seed_survives_the_sandbox_deleting_everything_it_can_reach() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
let seed = s.seed().unwrap();
let _ = git(&s.gitdir, &wt, &["tag", "-d", "session-start"]);
let _ = std::fs::remove_dir_all(s.gitdir.join("refs/tags"));
assert_eq!(s.seed().unwrap(), seed);
assert!(!seed.is_empty());
}
#[test]
fn the_agent_can_commit_without_any_identity_of_its_own() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join("f.txt"), "base\nthe agent's work\n").unwrap();
let out = Command::new("git")
.current_dir(&wt)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.arg("--git-dir")
.arg(&s.gitdir)
.arg("--work-tree")
.arg(&wt)
.args(["commit", "-q", "-am", "a checkpoint"])
.output()
.unwrap();
assert!(
out.status.success(),
"a sandbox with no git identity must still be able to check point: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
for (key, want) in [("user.name", AUTHOR_NAME), ("user.email", AUTHOR_EMAIL)] {
let got = git(&s.gitdir, &wt, &["config", "--local", key]).unwrap_or_default();
assert_eq!(
got.trim(),
want,
"the repository has to bring its own {key}; nothing in a \
container supplies one"
);
}
}
#[test]
fn a_capability_added_later_is_still_kept_out_of_the_sandboxs_history() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[".env".to_string()]).unwrap();
std::fs::write(wt.join("agent.rs"), "fn main() {}").unwrap();
git(&s.gitdir, &wt, &["add", "-A"]).unwrap();
git(&s.gitdir, &wt, &["commit", "-q", "-m", "a checkpoint"]).unwrap();
let checkpoint = git(&s.gitdir, &wt, &["rev-parse", "HEAD"]).unwrap();
let grown = [".env".to_string(), ".mcp.json".to_string()];
s.ensure(&wt, &grown).unwrap();
std::fs::write(wt.join(".mcp.json"), "{\"env\":{\"TOKEN\":\"sk-live-42\"}}").unwrap();
git(&s.gitdir, &wt, &["add", "-A", "."]).unwrap();
let staged = git(&s.gitdir, &wt, &["diff", "--cached", "--name-only"]).unwrap();
assert!(
!staged.contains(".mcp.json"),
"a document omh mounted is not the agent's work to commit: staged {staged:?}"
);
assert_eq!(
git(&s.gitdir, &wt, &["rev-parse", "HEAD"]).unwrap(),
checkpoint,
"and refreshing the list must not disturb what the agent already did"
);
}
#[test]
fn seeding_twice_keeps_the_work_the_agent_already_did() {
let (_d, wt, shadow_dir) = fixture();
let s = Shadow::new(&shadow_dir, "s01");
s.ensure(&wt, &[]).unwrap();
std::fs::write(wt.join("agent.rs"), "fn main() {}").unwrap();
git(&s.gitdir, &wt, &["add", "-A"]).unwrap();
git(&s.gitdir, &wt, &["commit", "-q", "-m", "a checkpoint"]).unwrap();
let checkpoint = git(&s.gitdir, &wt, &["rev-parse", "HEAD"]).unwrap();
s.ensure(&wt, &[]).unwrap();
assert_eq!(
git(&s.gitdir, &wt, &["rev-parse", "HEAD"]).unwrap(),
checkpoint,
"a relaunch must not discard the agent's checkpoints"
);
}
}