use crate::{Error, Result};
use ostraka_core::identity::ActorId;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct Worktree {
path: PathBuf,
branch: String,
}
impl Worktree {
pub fn path(&self) -> &Path {
&self.path
}
pub fn branch(&self) -> &str {
&self.branch
}
}
pub fn is_repository(dir: &Path) -> bool {
Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
pub fn has_a_commit(repo: &Path) -> bool {
Command::new("git")
.args(["rev-parse", "--verify", "HEAD"])
.current_dir(repo)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
pub fn create(repo: &Path, base: &Path, run_id: &str, base_ref: &str) -> Result<Worktree> {
let path = base.join(run_id);
let branch = format!("ostraka/{run_id}");
let out = Command::new("git")
.args(["worktree", "add", "-b", &branch])
.arg(&path)
.arg(base_ref)
.current_dir(repo)
.output()?;
if !out.status.success() {
return Err(Error::Other(format!(
"git worktree add failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(Worktree { path, branch })
}
fn exclude(worktree: &Path, name: &str) {
let Ok(out) = Command::new("git")
.args(["rev-parse", "--git-path", "info/exclude"])
.current_dir(worktree)
.output()
else {
return;
};
if !out.status.success() {
return;
}
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
if path.is_empty() {
return;
}
let path = worktree.join(path);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if existing.lines().any(|line| line.trim() == name) {
return;
}
use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(file, "{name}");
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetupProblem {
pub step: String,
pub reason: String,
}
pub fn notes_linked(worktree: &Path) -> bool {
linked(worktree, "notes")
}
pub fn linked(worktree: &Path, name: &str) -> bool {
let path = worktree.join(name);
let Ok(meta) = std::fs::symlink_metadata(&path) else {
return false;
};
if !meta.file_type().is_symlink() {
return false;
}
let (Ok(root), Ok(resolved)) = (worktree.canonicalize(), path.canonicalize()) else {
return false;
};
!resolved.starts_with(&root)
}
pub fn prepare(
project: &Path,
worktree: &Path,
config: &ostraka_core::config::WorktreeConfig,
notes: Option<&Path>,
skills: Option<&Path>,
ceiling: Option<std::time::Duration>,
) -> std::result::Result<Vec<String>, SetupProblem> {
let mut done = Vec::new();
let linked: Vec<(String, PathBuf)> = notes
.map(|path| ("notes".to_string(), path.to_path_buf()))
.into_iter()
.chain(skills.map(|path| ("skills".to_string(), path.to_path_buf())))
.chain(config.link.iter().map(|n| (n.clone(), project.join(n))))
.collect();
for (name, source) in linked {
let name = &name;
let target = worktree.join(name);
if !source.exists() {
return Err(SetupProblem {
step: format!("link {name}"),
reason: format!(
"{} is declared in [worktree] link and is not there; the worktree cannot be \
prepared without it",
source.display()
),
});
}
if target.exists() || std::fs::symlink_metadata(&target).is_ok() {
continue;
}
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
let source = source.canonicalize().unwrap_or(source);
if let Err(e) = symlink(&source, &target) {
return Err(SetupProblem {
step: format!("link {name}"),
reason: format!("could not link {} into the worktree: {e}", source.display()),
});
}
exclude(worktree, name);
done.push(format!("link {name}"));
}
if let Some(command) = config.setup.as_deref().filter(|c| !c.trim().is_empty()) {
let record = crate::gate::run_command(command, worktree, ceiling);
if record.exit_code != Some(0) {
let tail: Vec<&str> = record
.stderr
.lines()
.rev()
.take(6)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
return Err(SetupProblem {
step: "setup".to_string(),
reason: format!(
"`{command}` exited with {}: {}",
record
.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "no exit code".to_string()),
if tail.is_empty() {
"and said nothing".to_string()
} else {
tail.join(" / ")
}
),
});
}
done.push(format!("setup `{command}`"));
}
Ok(done)
}
#[cfg(unix)]
fn symlink(source: &Path, target: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(source, target)
}
#[cfg(windows)]
fn symlink(source: &Path, target: &Path) -> std::io::Result<()> {
if source.is_dir() {
std::os::windows::fs::symlink_dir(source, target)
} else {
std::os::windows::fs::symlink_file(source, target)
}
}
pub fn touched_paths(worktree: &Path) -> Result<Vec<String>> {
let out = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree)
.output()?;
if !out.status.success() {
return Err(Error::Other(format!(
"git status failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
line.get(3..).map(|p| p.trim().to_string())
})
.filter(|p| !p.is_empty())
.collect())
}
pub fn diff(worktree: &Path) -> Result<String> {
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(worktree)
.output()?;
if !add.status.success() {
return Err(Error::Other(format!(
"git add failed: {}",
String::from_utf8_lossy(&add.stderr).trim()
)));
}
let out = Command::new("git")
.args(["diff", "--cached"])
.current_dir(worktree)
.output()?;
if !out.status.success() {
return Err(Error::Other(format!(
"git diff failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn commit(worktree: &Path, message: &str, author: &ActorId) -> Result<()> {
let out = Command::new("git")
.arg("-c")
.arg(format!("user.name={author}"))
.arg("-c")
.arg(format!(
"user.email={}@ostraka.invalid",
email_local(author)
))
.args(["commit", "-m", message])
.current_dir(worktree)
.output()?;
if !out.status.success() {
return Err(Error::Other(format!(
"git commit failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
fn email_local(author: &ActorId) -> String {
let cleaned: String = author
.as_str()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
if cleaned.is_empty() {
"agent".to_string()
} else {
cleaned
}
}
pub fn release(repo: &Path, wt: &Worktree) -> Result<()> {
remove_checkout(repo, &wt.path)
}
pub fn release_path(repo: &Path, path: &Path) -> Result<()> {
remove_checkout(repo, path)
}
fn remove_checkout(repo: &Path, path: &Path) -> Result<()> {
let out = Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(path)
.current_dir(repo)
.output()?;
if !out.status.success() {
return Err(Error::Other(format!(
"git worktree remove failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
pub fn list(repo: &Path, base: &Path) -> Result<Vec<PathBuf>> {
if !base.is_dir() {
return Ok(Vec::new());
}
let _ = repo;
let mut found: Vec<PathBuf> = std::fs::read_dir(base)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_dir())
.collect();
found.sort();
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
use ostraka_core::config::WorktreeConfig;
fn scratch(name: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("ostraka-prep-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(path.join("project")).expect("project");
std::fs::create_dir_all(path.join("wt")).expect("worktree");
path
}
fn prep_config(link: &[&str], setup: Option<&str>) -> WorktreeConfig {
WorktreeConfig {
base: "worktrees".into(),
link: link.iter().map(|s| (*s).to_string()).collect(),
setup: setup.map(str::to_string),
}
}
#[test]
fn what_git_ignores_is_linked_into_the_checkout() {
let dir = scratch("link");
std::fs::create_dir_all(dir.join("project/node_modules")).expect("deps");
std::fs::write(dir.join("project/node_modules/marker"), "here").expect("write");
let done = prepare(
&dir.join("project"),
&dir.join("wt"),
&prep_config(&["node_modules"], None),
None,
None,
None,
)
.expect("prepares");
assert_eq!(done, ["link node_modules"]);
assert_eq!(
std::fs::read_to_string(dir.join("wt/node_modules/marker")).expect("reads"),
"here"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_workspaces_notes_reach_every_worktree_without_being_configured() {
let dir = scratch("notes");
std::fs::create_dir_all(dir.join("notes")).expect("notes");
std::fs::create_dir_all(dir.join("project")).expect("project");
std::fs::write(dir.join("notes/earlier.md"), "what was worked out").expect("write");
let done = prepare(
&dir.join("project"),
&dir.join("wt"),
&prep_config(&[], None),
Some(&dir.join("notes")),
None,
None,
)
.expect("prepares");
assert_eq!(done, ["link notes"]);
assert_eq!(
std::fs::read_to_string(dir.join("wt/notes/earlier.md")).expect("reads"),
"what was worked out"
);
std::fs::write(dir.join("wt/notes/during.md"), "what was learned").expect("write");
assert!(
dir.join("notes/during.md").is_file(),
"the note stayed in the worktree"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_worktree_without_notes_is_prepared_anyway() {
let dir = scratch("no-notes");
std::fs::create_dir_all(dir.join("project")).expect("project");
let done = prepare(
&dir.join("project"),
&dir.join("wt"),
&prep_config(&[], None),
None,
None,
None,
)
.expect("prepares");
assert!(done.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_link_is_absolute_so_the_worktree_depth_does_not_matter() {
let dir = scratch("absolute");
std::fs::create_dir_all(dir.join("project/node_modules")).expect("deps");
std::fs::create_dir_all(dir.join("wt/deep/deeper")).expect("deep");
prepare(
&dir.join("project"),
&dir.join("wt/deep/deeper"),
&prep_config(&["node_modules"], None),
None,
None,
None,
)
.expect("prepares");
let link = std::fs::read_link(dir.join("wt/deep/deeper/node_modules")).expect("a link");
assert!(link.is_absolute(), "{link:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_declared_link_that_is_absent_is_said_plainly() {
let dir = scratch("missing");
let problem = prepare(
&dir.join("project"),
&dir.join("wt"),
&prep_config(&["node_modules"], None),
None,
None,
None,
)
.expect_err("must refuse");
assert_eq!(problem.step, "link node_modules");
assert!(problem.reason.contains("is not there"), "{problem:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn something_the_repository_tracks_is_not_replaced_by_a_link() {
let dir = scratch("tracked");
std::fs::create_dir_all(dir.join("project/vendor")).expect("source");
std::fs::create_dir_all(dir.join("wt/vendor")).expect("checked out");
std::fs::write(dir.join("wt/vendor/theirs"), "tracked").expect("write");
prepare(
&dir.join("project"),
&dir.join("wt"),
&prep_config(&["vendor"], None),
None,
None,
None,
)
.expect("prepares");
assert!(
dir.join("wt/vendor/theirs").is_file(),
"the checkout lost a tracked file"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_setup_command_that_fails_reports_the_environment_not_the_change() {
let dir = scratch("setup-fails");
let problem = prepare(
&dir.join("project"),
&dir.join("wt"),
&prep_config(&[], Some("echo no registry >&2; exit 1")),
None,
None,
None,
)
.expect_err("must refuse");
assert_eq!(problem.step, "setup");
assert!(problem.reason.contains("no registry"), "{problem:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_setup_command_runs_inside_the_worktree() {
let dir = scratch("setup-cwd");
prepare(
&dir.join("project"),
&dir.join("wt"),
&prep_config(&[], Some("pwd > where")),
None,
None,
None,
)
.expect("prepares");
let ran_in = std::fs::read_to_string(dir.join("wt/where")).expect("reads");
assert!(ran_in.trim().ends_with("wt"), "{ran_in}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn nothing_declared_means_nothing_done() {
let dir = scratch("nothing");
let done = prepare(
&dir.join("project"),
&dir.join("wt"),
&prep_config(&[], None),
None,
None,
None,
)
.expect("prepares");
assert!(done.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_identity_with_spaces_still_yields_a_usable_address() {
assert_eq!(email_local(&ActorId::new("agent archon")), "agent-archon");
assert_eq!(email_local(&ActorId::new("archon")), "archon");
}
#[test]
fn an_empty_identity_falls_back_rather_than_producing_an_at_sign_alone() {
assert_eq!(email_local(&ActorId::new("")), "agent");
}
}
#[cfg(test)]
mod linked_tests {
use super::linked;
fn scratch(name: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("ostraka-linked-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("wt")).expect("worktree");
dir
}
#[cfg(unix)]
#[test]
fn a_relative_link_that_stays_inside_the_checkout_is_not_the_workspaces() {
let dir = scratch("relative-inside");
std::fs::create_dir_all(dir.join("wt/sub")).expect("sub");
std::os::unix::fs::symlink("sub", dir.join("wt/notes")).expect("link");
assert!(!linked(&dir.join("wt"), "notes"));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_link_out_of_the_checkout_is_the_workspaces_however_it_is_written() {
let dir = scratch("outside");
std::fs::create_dir_all(dir.join("shared")).expect("shared");
std::os::unix::fs::symlink(dir.join("shared"), dir.join("wt/notes")).expect("absolute");
std::os::unix::fs::symlink("../shared", dir.join("wt/skills")).expect("relative");
assert!(linked(&dir.join("wt"), "notes"), "absolute target");
assert!(linked(&dir.join("wt"), "skills"), "relative target");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_link_to_nothing_is_not_a_directory_anybody_can_be_told_about() {
let dir = scratch("broken");
std::os::unix::fs::symlink("../never-existed", dir.join("wt/notes")).expect("link");
assert!(!linked(&dir.join("wt"), "notes"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_real_directory_is_the_repositorys_own() {
let dir = scratch("real");
std::fs::create_dir_all(dir.join("wt/notes")).expect("notes");
assert!(!linked(&dir.join("wt"), "notes"));
assert!(!linked(&dir.join("wt"), "absent"));
let _ = std::fs::remove_dir_all(&dir);
}
}