use std::path::{Path, PathBuf};
use std::process::Command;
use color_eyre::{eyre::eyre, Result};
use crate::core::stack::{PatchEntry, PatchStatus};
pub struct Repo {
pub workdir: PathBuf,
}
impl Repo {
pub fn open() -> Result<Self> {
let output = git_global(&["rev-parse", "--show-toplevel"])?;
let workdir = PathBuf::from(output.trim());
Ok(Self { workdir })
}
pub fn detect_base(&self) -> Result<String> {
for candidate in &["origin/main", "origin/master", "main", "master"] {
if self.git(&["rev-parse", "--verify", "--quiet", candidate]).is_ok() {
return Ok(candidate.to_string());
}
}
Err(eyre!(
"Could not detect base branch. Set it with `pgit config --base <branch>`."
))
}
pub fn get_head_hash(&self) -> Result<String> {
Ok(self.git(&["rev-parse", "HEAD"])?.trim().to_string())
}
pub fn get_current_branch(&self) -> Result<String> {
Ok(self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?.trim().to_string())
}
pub fn reset_hard(&self, hash: &str) -> Result<()> {
self.git(&["reset", "--hard", hash])?;
Ok(())
}
pub fn list_stack_commits(&self) -> Result<Vec<PatchEntry>> {
let base = self.detect_base()?;
let range = format!("{}..HEAD", base);
let format = "%H%x1f%s%x1f%b%x1f%an%x1f%ai%x1e";
let output = self.git(&["log", "--reverse", &format!("--format={}", format), &range])?;
let mut patches = Vec::new();
for record in output.split('\x1e') {
let record = record.trim();
if record.is_empty() {
continue;
}
let parts: Vec<&str> = record.splitn(5, '\x1f').collect();
if parts.len() < 5 {
continue;
}
patches.push(PatchEntry {
hash: parts[0].to_string(),
subject: parts[1].to_string(),
body: parts[2].trim().to_string(),
author: parts[3].to_string(),
timestamp: parts[4].trim().to_string(),
pr_branch: None,
pr_number: None,
pr_url: None,
status: PatchStatus::Clean,
});
}
Ok(patches)
}
pub fn diff_full(&self, hash: &str) -> Result<String> {
self.git(&["show", "--format=", hash])
}
pub fn has_uncommitted_changes(&self) -> bool {
let output = Command::new("git")
.current_dir(&self.workdir)
.args(["status", "--porcelain"])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.lines()
.any(|l| !l.ends_with(".pilegit.toml"))
}
Err(_) => false,
}
}
pub fn fetch_origin(&self) -> Result<()> {
self.git(&["fetch", "origin"])?;
Ok(())
}
pub fn rebase_onto_base(&self, on_progress: &dyn Fn(&str)) -> Result<bool> {
let base = self.detect_base()?;
on_progress("Fetching from origin...");
let _ = self.fetch_origin();
on_progress(&format!("Rebasing onto {}...", base));
let result = Command::new("git")
.current_dir(&self.workdir)
.args(["rebase", &base])
.output()?;
if result.status.success() && !self.is_rebase_in_progress() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&result.stderr);
if stderr.contains("CONFLICT") || stderr.contains("could not apply")
|| self.is_rebase_in_progress()
{
return Ok(false);
}
Err(eyre!("Rebase failed: {}", stderr))
}
pub fn rebase_continue(&self) -> Result<bool> {
let result = Command::new("git")
.current_dir(&self.workdir)
.env("GIT_EDITOR", "true") .args(["rebase", "--continue"])
.output()?;
if result.status.success() && !self.is_rebase_in_progress() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&result.stderr);
if stderr.contains("CONFLICT") || stderr.contains("could not apply")
|| self.is_rebase_in_progress()
{
return Ok(false);
}
Err(eyre!("Rebase continue failed: {}", stderr))
}
pub fn rebase_abort(&self) -> Result<()> {
self.git(&["rebase", "--abort"])?;
Ok(())
}
fn abbrev(&self, hash: &str) -> String {
self.git(&["rev-parse", "--short", hash])
.unwrap_or_else(|_| hash.to_string())
.trim().to_string()
}
pub fn rebase_edit_commit(&self, short_hash: &str) -> Result<bool> {
let base = self.detect_base()?;
let abbr = self.abbrev(short_hash);
let sed_cmd = format!(
"sed -i 's/^pick {}/edit {}/'",
abbr, abbr
);
let _result = Command::new("git")
.current_dir(&self.workdir)
.env("GIT_SEQUENCE_EDITOR", &sed_cmd)
.args(["rebase", "-i", &base])
.output()?;
if self.is_rebase_in_progress() {
return Ok(false); }
Ok(true) }
pub fn rebase_break_after(&self, short_hash: &str) -> Result<bool> {
let base = self.detect_base()?;
let abbr = self.abbrev(short_hash);
let sed_cmd = format!(
"sed -i '/^pick {}/a break'",
abbr
);
let _result = Command::new("git")
.current_dir(&self.workdir)
.env("GIT_SEQUENCE_EDITOR", &sed_cmd)
.args(["rebase", "-i", &base])
.output()?;
if self.is_rebase_in_progress() {
return Ok(false); }
Ok(true) }
pub fn squash_commits_with_message(&self, hashes: &[String], message: &str) -> Result<bool> {
if hashes.len() < 2 {
return Err(eyre!("Need at least 2 commits to squash"));
}
let base = self.detect_base()?;
let sed_parts: Vec<String> = hashes[1..]
.iter()
.map(|h| {
let abbr = self.abbrev(h);
format!("s/^pick {}/squash {}/", abbr, abbr)
})
.collect();
let seq_editor = format!("sed -i '{}'", sed_parts.join("; "));
let msg_file = std::env::temp_dir().join(format!(
"pgit-squash-msg-{}.txt",
std::process::id()
));
std::fs::write(&msg_file, message)?;
let msg_editor = format!("cp {} ", msg_file.display());
let result = Command::new("git")
.current_dir(&self.workdir)
.env("GIT_SEQUENCE_EDITOR", &seq_editor)
.env("GIT_EDITOR", &msg_editor)
.args(["rebase", "-i", &base])
.output()?;
let _ = std::fs::remove_file(&msg_file);
if result.status.success() && !self.is_rebase_in_progress() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&result.stderr);
if stderr.contains("CONFLICT") || stderr.contains("could not apply")
|| self.is_rebase_in_progress()
{
return Ok(false);
}
Err(eyre!("Squash failed: {}", stderr))
}
pub fn remove_commit(&self, short_hash: &str) -> Result<bool> {
let base = self.detect_base()?;
let abbr = self.abbrev(short_hash);
let sed_cmd = format!(
"sed -i 's/^pick {}/drop {}/'",
abbr, abbr
);
let result = Command::new("git")
.current_dir(&self.workdir)
.env("GIT_SEQUENCE_EDITOR", &sed_cmd)
.args(["rebase", "-i", &base])
.output()?;
if result.status.success() && !self.is_rebase_in_progress() {
return Ok(true); }
let stderr = String::from_utf8_lossy(&result.stderr);
if stderr.contains("CONFLICT") || stderr.contains("could not apply")
|| self.is_rebase_in_progress()
{
return Ok(false); }
Err(eyre!("Remove commit failed: {}", stderr))
}
pub fn swap_commits(&self, hash_below: &str, hash_above: &str) -> Result<bool> {
let base = self.detect_base()?;
let abbrev_below = self.abbrev(hash_below);
let abbrev_above = self.abbrev(hash_above);
let sed_cmd = format!(
"sed -i '/^pick {}/{{ h; d }}; /^pick {}/{{ p; x }}'",
abbrev_below, abbrev_above
);
let result = Command::new("git")
.current_dir(&self.workdir)
.env("GIT_SEQUENCE_EDITOR", &sed_cmd)
.args(["rebase", "-i", &base])
.output()?;
if result.status.success() && !self.is_rebase_in_progress() {
return Ok(true); }
let stderr = String::from_utf8_lossy(&result.stderr);
if stderr.contains("CONFLICT") || stderr.contains("could not apply")
|| self.is_rebase_in_progress()
{
return Ok(false); }
Err(eyre!("Swap commits failed: {}", stderr))
}
pub fn is_rebase_in_progress(&self) -> bool {
self.workdir.join(".git/rebase-merge").exists()
|| self.workdir.join(".git/rebase-apply").exists()
}
pub fn conflicted_files(&self) -> Result<Vec<String>> {
let output = self.git(&["diff", "--name-only", "--diff-filter=U"])?;
Ok(output
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect())
}
pub fn determine_base_for_commit(
&self,
patches: &[crate::core::stack::PatchEntry],
commit_index: usize,
open_prs: &std::collections::HashMap<String, u32>,
gh_available: bool,
) -> String {
let base = self.detect_base().unwrap_or_else(|_| "main".into());
let base_branch = base.strip_prefix("origin/").unwrap_or(&base).to_string();
if commit_index == 0 {
return base_branch;
}
for j in (0..commit_index).rev() {
let parent = &patches[j];
let parent_branch = self.make_pgit_branch_name(&parent.subject);
if gh_available {
if open_prs.contains_key(&parent_branch) {
let _ = self.git(&["branch", "-f", &parent_branch, &parent.hash]);
let _ = self.git(&["push", "-f", "origin", &parent_branch]);
return parent_branch;
}
} else if self.git(&["rev-parse", "--verify", &parent_branch]).is_ok() {
let _ = self.git(&["branch", "-f", &parent_branch, &parent.hash]);
let _ = self.git(&["push", "-f", "origin", &parent_branch]);
return parent_branch;
}
}
base_branch
}
pub fn make_pgit_branch_name(&self, subject: &str) -> String {
let user = self.get_pgit_username();
let sanitized: String = subject
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c.to_ascii_lowercase() } else { '-' })
.collect();
let sanitized = sanitized.trim_matches('-');
let truncated = &sanitized[..50.min(sanitized.len())];
format!("pgit/{}/{}", user, truncated.trim_end_matches('-'))
}
fn get_pgit_username(&self) -> String {
let name = self.git(&["config", "user.name"])
.map(|s| s.trim().to_string())
.unwrap_or_default();
let name = if name.is_empty() {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_string())
} else {
name
};
let sanitized: String = name
.chars()
.map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect();
let sanitized = sanitized.trim_matches('-');
sanitized[..20.min(sanitized.len())].trim_end_matches('-').to_string()
}
pub fn list_pgit_branches(&self) -> Vec<String> {
let user = self.get_pgit_username();
let prefix = format!("pgit/{}/", user);
let local = self.git(&["branch", "--list", &format!("{}*", prefix), "--format=%(refname:short)"])
.unwrap_or_default();
local.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
}
pub fn branch_is_in_base(&self, branch: &str) -> bool {
let base = self.detect_base().unwrap_or_else(|_| "origin/main".to_string());
self.git(&["merge-base", "--is-ancestor", branch, &base]).is_ok()
}
pub fn find_stale_branches_with(
&self,
open_prs: &std::collections::HashMap<String, u32>,
gh_available: bool,
) -> Vec<String> {
let local_branches = self.list_pgit_branches();
if local_branches.is_empty() { return Vec::new(); }
local_branches.into_iter()
.filter(|b| {
if gh_available && !open_prs.is_empty() && !open_prs.contains_key(b) {
return true;
}
self.branch_is_in_base(b)
})
.collect()
}
pub fn delete_branches(&self, branches: &[String]) {
for branch in branches {
let _ = self.git(&["branch", "-D", branch]);
let _ = self.git(&["push", "origin", "--delete", branch]);
}
}
pub fn git_pub(&self, args: &[&str]) -> Result<String> {
git_in(&self.workdir, args)
}
pub fn walk_stack_for_base(
&self,
patches: &[crate::core::stack::PatchEntry],
commit_index: usize,
open_prs: &std::collections::HashMap<String, u32>,
base_branch: &str,
) -> String {
if commit_index == 0 { return base_branch.to_string(); }
for j in (0..commit_index).rev() {
let parent = &patches[j];
let parent_branch = self.make_pgit_branch_name(&parent.subject);
if open_prs.contains_key(&parent_branch) {
let _ = self.git(&["branch", "-f", &parent_branch, &parent.hash]);
let _ = self.git(&["push", "-f", "origin", &parent_branch]);
return parent_branch;
}
}
base_branch.to_string()
}
fn git(&self, args: &[&str]) -> Result<String> {
git_in(&self.workdir, args)
}
}
fn git_in(workdir: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.current_dir(workdir)
.args(args)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(eyre!("git {} failed: {}", args.join(" "), stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn git_global(args: &[&str]) -> Result<String> {
let output = Command::new("git").args(args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(eyre!("git {} failed: {}", args.join(" "), stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}