pub mod git_apply;
pub mod git_branch;
pub mod git_commit;
pub mod git_diff;
pub mod git_merge;
pub mod git_rebase;
pub mod git_worktree;
pub use git_apply::{
Replay, apply_cached_patch, apply_cached_patch_reverse, apply_patch, apply_patch_reverse,
apply_patch_to_worktree, apply_patch_with_index, apply_patch_with_index_reverse,
restore_loom_unstaged, restore_or_park_after_abort, restore_staged_after_rebase,
restore_staged_patch, save_or_warn, save_patch_aside,
};
pub use git_branch::{
branch_create, branch_delete, branch_force_create, branch_rename, branch_switch,
branch_switch_create_tracking, branch_switch_detach, branch_validate_name,
};
pub use git_commit::{
commit, commit_amend, commit_amend_message_unverified, commit_amend_no_edit, commit_captured,
commit_opts, commit_with_editor, reset_hard, reset_mixed, reset_soft, stage_all,
stage_all_opts, stage_files, stage_files_opts, stage_from, stage_path,
};
pub use git_diff::{
diff_cached, diff_cached_display, diff_cached_file, diff_cached_file_is_binary,
diff_cached_files, diff_commit, diff_commit_file, diff_commit_file_is_binary,
diff_commit_name_status, diff_file, diff_file_is_binary, diff_head, diff_head_display,
diff_head_file, diff_head_file_display, diff_head_file_is_binary, diff_head_files,
diff_head_files_display, diff_head_name_only, diff_range, diff_trees, show_commit_file,
show_commit_patch,
};
pub use git_merge::{MergeOutcome, continue_merge, merge_abort, merge_is_in_progress, merge_no_ff};
#[cfg(test)]
pub use git_rebase::rebase_onto;
pub use git_rebase::{
AfterStop, Protected, REPLAYS_EMPTY, RebaseOutcome, StopId, abort_after_failure, auto_merge_id,
before_rebase_starts, carry_past_known_stops, continue_rebase, continue_rebase_expecting_edit,
continue_rerere_stops, finished_without_stopping, has_unmerged_paths, rebase, rebase_abort,
rebase_abort_then_cleanup, rebase_is_in_progress, rebase_is_over, rebase_never_started,
rebase_outcome, rebase_progress, replayed_empty_hash, stop_id, unmerged_paths,
verify_paused_at,
};
pub use git_worktree::ensure_not_checked_out_elsewhere;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use anyhow::{Context, Result, bail};
use crate::trace as loom_trace;
pub const FORCED_CONFIG: &[&str] = &[
"-c",
"commit.verbose=false",
"-c",
"diff.noprefix=false",
"-c",
"diff.mnemonicPrefix=false",
"-c",
"diff.srcPrefix=a/",
"-c",
"diff.dstPrefix=b/",
"-c",
"apply.whitespace=nowarn",
"-c",
"apply.ignoreWhitespace=no",
"-c",
"rebase.missingCommitsCheck=ignore",
];
pub const ABORT_FAILED_CAUSE: &str =
"a stale `.git/index.lock` or a concurrent git process is the usual cause";
const MIN_GIT_VERSION: (u32, u32) = (2, 40);
pub fn absolute_git_dir(workdir: &Path) -> Result<PathBuf> {
let out = run_git_stdout(workdir, &["rev-parse", "--absolute-git-dir"])?;
Ok(PathBuf::from(out.trim()))
}
pub fn reaches_from_head(workdir: &Path, rev: &str) -> bool {
run_git(workdir, &["merge-base", "--is-ancestor", rev, "HEAD"]).is_ok()
}
fn run_git_captured(workdir: &Path, args: &[&str]) -> Result<std::process::Output> {
let start = Instant::now();
let output = Command::new("git")
.current_dir(workdir)
.args(FORCED_CONFIG)
.args(args)
.env("GIT_EDITOR", "true")
.output()?;
let duration_ms = start.elapsed().as_millis();
let stderr = String::from_utf8_lossy(&output.stderr);
let cmd = args.join(" ");
loom_trace::log_command("git", &cmd, duration_ms, output.status.success(), &stderr);
if !output.status.success() {
bail!("git {} failed", args[0]);
}
Ok(output)
}
pub fn run_git(workdir: &Path, args: &[&str]) -> Result<()> {
run_git_captured(workdir, args).map(|_| ())
}
pub fn run_git_stdout(workdir: &Path, args: &[&str]) -> Result<String> {
let output = run_git_captured(workdir, args)?;
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn run_git_combined(workdir: &Path, args: &[&str]) -> Result<String> {
let output = run_git_captured(workdir, args)?;
let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&output.stderr));
Ok(combined.trim().to_string())
}
pub fn check_git_version() -> Result<()> {
let version_str = git_version_output();
if version_str.is_empty() {
bail!("Could not run git — is it installed and on PATH?");
}
let (major, minor) = parse_git_version(&version_str)
.with_context(|| format!("Could not parse Git version from: {}", version_str.trim()))?;
if (major, minor) < MIN_GIT_VERSION {
bail!(
"Git {}.{} is too old, git-loom requires Git {}.{} or later\n\
Current version: {}",
major,
minor,
MIN_GIT_VERSION.0,
MIN_GIT_VERSION.1,
version_str.trim()
);
}
Ok(())
}
pub fn empty_stop_value() -> &'static str {
static VALUE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
VALUE.get_or_init(|| empty_stop_for(parse_git_version(&git_version_output())))
}
fn git_version_output() -> String {
static OUTPUT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
OUTPUT
.get_or_init(|| {
Command::new("git")
.arg("--version")
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).into_owned())
.unwrap_or_default()
})
.clone()
}
fn empty_stop_for(version: Option<(u32, u32)>) -> &'static str {
match version {
Some(version) if version >= (2, 45) => "stop",
_ => "ask",
}
}
fn parse_git_version(version_str: &str) -> Option<(u32, u32)> {
let version_part = version_str.trim().strip_prefix("git version ")?;
let mut parts = version_part.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
Some((major, minor))
}
pub fn run_git_interactive(workdir: &Path, args: &[&str]) -> Result<()> {
let mut full_args: Vec<&str> = Vec::new();
if crate::core::agent_mode::enabled() {
full_args.extend(["-c", "core.pager=cat"]);
}
full_args.extend(args);
let start = Instant::now();
let terminal = crate::core::ui::suspend()?;
let mut command = Command::new("git");
command.current_dir(workdir).args(&full_args);
#[cfg(test)]
command.env_remove("GIT_EDITOR");
let status = command.status()?;
drop(terminal);
let duration_ms = start.elapsed().as_millis();
let cmd = args.join(" ");
loom_trace::log_command("git", &cmd, duration_ms, status.success(), "");
if !status.success() {
bail!("git {} failed", args[0]);
}
Ok(())
}
pub fn unstage_files(workdir: &Path, files: &[&str]) -> Result<()> {
let mut args = vec!["reset", "HEAD", "--"];
args.extend(files);
run_git(workdir, &args)
}
pub fn restore_files_to_head(workdir: &Path, files: &[&str]) -> Result<()> {
let mut args = vec!["checkout", "HEAD", "--"];
args.extend(files);
run_git(workdir, &args)
}
pub fn checkout_index_force(workdir: &Path, files: &[&str]) -> Result<()> {
let mut args = vec!["checkout-index", "-f", "--"];
args.extend(files);
run_git(workdir, &args)
}
pub fn ls_files(workdir: &Path, files: &[&str]) -> Result<Vec<String>> {
if files.is_empty() {
return Ok(Vec::new());
}
let literal: Vec<String> = files.iter().map(|f| format!(":(literal){f}")).collect();
let mut args = vec!["ls-files", "-z", "--"];
args.extend(literal.iter().map(|f| f.as_str()));
Ok(run_git_stdout(workdir, &args)?
.split('\0')
.filter(|p| !p.is_empty())
.map(|p| p.to_string())
.collect())
}
pub fn index_gitlinks(workdir: &Path) -> Result<std::collections::HashSet<String>> {
let out = run_git_stdout(workdir, &["ls-files", "-s", "-z"])?;
Ok(out
.split('\0')
.filter_map(|entry| entry.strip_prefix("160000 "))
.filter_map(|entry| entry.split_once('\t'))
.map(|(_, path)| path.to_string())
.collect())
}
pub fn commit_gitlinks(
workdir: &Path,
oid: &str,
) -> Result<std::collections::HashMap<String, bool>> {
let parent = format!("{oid}^");
let out = run_git_stdout(workdir, &["diff-tree", "-r", "-z", &parent, oid])?;
let mut gitlinks = std::collections::HashMap::new();
let mut fields = out.split('\0').filter(|f| !f.is_empty());
while let Some(field) = fields.next() {
let Some(meta) = field.strip_prefix(':') else {
continue;
};
let Some(path) = fields.next() else { break };
let mut modes = meta.split(' ');
let src = modes.next().unwrap_or("");
let dst = modes.next().unwrap_or("");
if src == "160000" || dst == "160000" {
gitlinks.insert(path.to_string(), src == "160000" && dst == "000000");
}
}
Ok(gitlinks)
}
pub fn remove_from_index(workdir: &Path, path: &str) -> Result<()> {
run_git(workdir, &["update-index", "--force-remove", "--", path])
}
pub fn worktree_dirty_paths(workdir: &Path) -> Result<std::collections::HashSet<String>> {
let out = run_git_stdout(workdir, &["status", "--porcelain", "-z", "-uall"])?;
let mut paths = std::collections::HashSet::new();
let mut rename_source_next = false;
for entry in out.split('\0').filter(|e| !e.is_empty()) {
if rename_source_next {
rename_source_next = false;
continue;
}
let Some((status, path)) = entry.split_at_checked(3) else {
continue;
};
rename_source_next = status.starts_with(['R', 'C']) || status[1..].starts_with(['R', 'C']);
if !status[1..].starts_with(' ') {
paths.insert(path.to_string());
}
}
Ok(paths)
}
pub fn write_tree(workdir: &Path) -> Result<String> {
Ok(run_git_stdout(workdir, &["write-tree"])?.trim().to_string())
}
pub fn git_path(workdir: &Path, name: &str) -> Result<PathBuf> {
let out = run_git_stdout(workdir, &["rev-parse", "--git-path", name])?;
Ok(workdir.join(out.trim_end_matches('\n')))
}
pub fn current_branch(workdir: &Path) -> Result<String> {
Ok(
run_git_stdout(workdir, &["symbolic-ref", "--quiet", "--short", "HEAD"])?
.trim()
.to_string(),
)
}
pub fn rev_parse(workdir: &Path, reference: &str) -> Result<String> {
let out = run_git_stdout(workdir, &["rev-parse", reference])?;
Ok(out.trim().to_string())
}
pub fn short_hash(hash: &str) -> &str {
&hash[..7.min(hash.len())]
}
pub fn loom_exe_path() -> Result<PathBuf> {
resolve_loom_exe(&std::env::current_exe()?)
}
fn resolve_loom_exe(exe: &Path) -> Result<PathBuf> {
if !cfg!(test) {
return Ok(exe.to_path_buf());
}
let Some(parent) = exe.parent() else {
return Ok(exe.to_path_buf());
};
if parent.file_name().and_then(|n| n.to_str()) != Some("deps") {
return Ok(exe.to_path_buf());
}
let Some(profile_dir) = parent.parent() else {
return Ok(exe.to_path_buf());
};
let actual = profile_dir.join(format!("git-loom{}", std::env::consts::EXE_SUFFIX));
if !actual.exists() {
bail!(
"'{}' does not exist — run `cargo build` before `cargo test`",
actual.display()
);
}
Ok(actual)
}
#[cfg(test)]
#[path = "mod_test.rs"]
mod tests;