Файли
openidea/crates/ide-vcs/src/lib.rs
T
l0ng-ai 9e35cfc064 feat(vcs): compare folder selections with revisions across repositories (#602)
Compare with Branch or Revision only handled one file, so reviewing a folder meant opening each file. Folders, the project root and multiple selections now open a changes tree against the chosen revision, with per-file diffs on demand. A single file keeps its direct diff.

Each path resolves against its own repository through the root registry. In a multi-repo workspace the picker offers shared revisions such as "main in 3 repositories" as well as per-repository ones, results are grouped by repository, and content outside any repository or a repository missing the revision is stated in the result. Listing is cancellable and off the UI thread, includes worktree and untracked changes, and a rename leaving the selection shows as a delete. Show Diff on folders reuses the same tree.

Verified on macOS, and against a remote host over the server protocol. A remote window, Windows and Linux were not run.

Fixes #567
2026-11-01 01:43:15 +08:00

1068 рядки
47 KiB
Rust
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! Git integration. Reads (status, diff, blame, log) use gitoxide in-process;
//! writes (commit, checkout, push/pull, stash, merge) shell out to `git` so
//! hooks, credentials and signing behave exactly as on the command line.
use ide_core::ignored::IgnoredNames;
use ide_core::{CancellationToken, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
pub use text_encoding::TextEncoding;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ChangeKind {
Added,
Modified,
Deleted,
Renamed,
Untracked,
Ignored,
Conflicted,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileStatus {
pub path: ide_core::IpcPath,
pub rel_path: String,
/// Staged change, if any.
pub staged: Option<ChangeKind>,
/// Unstaged (working tree) change, if any.
pub unstaged: Option<ChangeKind>,
pub renamed_from: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BranchInfo {
/// `None` when HEAD is detached.
pub head: Option<String>,
pub head_commit: Option<String>,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
/// Merge / rebase / cherry-pick in progress.
pub operation: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum HunkKind {
Added,
Modified,
Deleted,
}
/// Line-level change against HEAD for editor gutter markers (0-based lines).
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LineHunk {
pub kind: HunkKind,
pub start: u32,
/// Lines in the current text (0 for pure deletions).
pub lines: u32,
pub old_start: u32,
pub old_lines: u32,
/// Original text of the hunk, for the inline diff popup / rollback.
pub old_text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BlameLine {
pub commit: String,
pub author: String,
pub time_ms: f64,
pub summary: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Commit {
pub id: String,
pub short_id: String,
pub parents: Vec<String>,
pub author: String,
pub email: String,
pub time_ms: f64,
pub summary: String,
pub message: String,
pub refs: Vec<String>,
/// Committer time (Log › View Options › Commit Timestamp).
#[serde(default)]
pub commit_time_ms: f64,
}
/// Order of the Git Log (Graph Options › Sort).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum LogOrder {
/// `--topo-order`: lines of history are not intermixed.
#[default]
Topo,
/// `--date-order`: commit time, but never a parent before its children.
Date,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogQuery {
pub branch: Option<String>,
pub path: Option<ide_core::IpcPath>,
pub author: Option<String>,
pub text: Option<String>,
pub skip: u32,
pub limit: u32,
/// A revision range such as `@{u}..HEAD` (commits reachable from the right side but
/// not the left). Takes precedence over `branch`.
#[serde(default)]
pub range: Option<String>,
/// `text` is a regular expression (Log filter `.*`).
#[serde(default)]
pub text_regex: bool,
/// `text` matches case-sensitively (Log filter `Cc`).
#[serde(default)]
pub text_match_case: bool,
/// More starting revisions next to `branch` (Log Branch filter with several branches).
#[serde(default)]
pub branches: Vec<String>,
/// More author needles next to `author`; a commit matches any of them (Log User filter).
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub order: LogOrder,
/// Follow only the first parent of merges (`--first-parent`).
#[serde(default)]
pub first_parent: bool,
/// Leave out merge commits (`--no-merges`); their children link to their parents.
#[serde(default)]
pub no_merges: bool,
/// Committer time bounds (epoch ms; `since` inclusive, `until` exclusive). Parents
/// outside the range are rewritten to their nearest shown ancestors (or dropped when
/// older), so the graph stays connected.
#[serde(default)]
pub since_ms: Option<f64>,
#[serde(default)]
pub until_ms: Option<f64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Branch {
pub name: String,
pub remote: bool,
pub current: bool,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub last_commit_ms: f64,
/// Tip commit id.
#[serde(default)]
pub commit: String,
}
/// Commit Options (IDEA's commit options popup).
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitOptions {
pub amend: bool,
pub sign_off: bool,
/// `--author`, e.g. `Name <[email protected]>`; `None` or blank uses the configured user.
pub author: Option<String>,
/// False skips the pre-commit and commit-msg hooks (`--no-verify`).
pub run_hooks: bool,
/// Files committed with only some of their changes (IDEA's partial commit). They are
/// committed through a temporary index, so neither the working tree nor the other
/// entries of the index change; afterwards their index entries match the new commit.
#[serde(default)]
pub partial: Vec<PartialCommitFile>,
}
/// A file of a partial commit: which of its changes against HEAD to commit.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PartialCommitFile {
pub path: ide_core::IpcPath,
/// The hunks to commit, as [`diff_hunks`] of the HEAD and on-disk texts reports them.
pub hunks: Vec<HunkRange>,
}
/// A hunk identified by its line ranges (0-based), see [`LineHunk`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HunkRange {
pub start: u32,
pub lines: u32,
pub old_start: u32,
pub old_lines: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ChangedFile {
pub path: String,
pub kind: ChangeKind,
pub old_path: Option<String>,
}
/// A file changed across several commits: its content at `before` ↔ at `after`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RevisionChange {
/// Repository-relative path after the change (before it, for a deletion).
pub path: String,
pub kind: ChangeKind,
/// Path at `before`, for a rename.
pub old_path: Option<String>,
/// Commit holding the old content; `None` when the file was added.
pub before: Option<String>,
/// Commit holding the new content; `None` when the file was deleted.
pub after: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Stash {
pub index: u32,
pub message: String,
pub time_ms: f64,
}
/// Push dialog › Push tags: every tag, or only annotated tags on the pushed commits.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PushTags {
All,
CurrentBranch,
}
/// Options of [`Repo::push_branch_to`]: which commits to push and where to.
#[derive(Clone, Copy, Debug, Default)]
pub struct PushBranchOptions<'a> {
/// Push only up to this commit of the branch (`None`: the whole branch).
pub revision: Option<&'a str>,
/// Remote branch name (`None` keeps the upstream's / the local name).
pub target: Option<&'a str>,
/// Remote to push to (`None` keeps the upstream's / the default remote).
pub remote: Option<&'a str>,
/// `git push --force-with-lease`.
pub force_with_lease: bool,
}
/// What an interactive rebase does with one commit.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RebaseAction {
Pick,
/// Pick with a new message ([`RebaseEntry::message`]).
Reword,
/// Stop after applying, to amend it (Stop to Edit).
Edit,
/// Meld into the previous commit, combining the messages.
Squash,
/// Meld into the previous commit, keeping the previous message.
Fixup,
Drop,
}
/// One line of an interactive rebase plan.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RebaseEntry {
pub commit: String,
pub action: RebaseAction,
/// New message for a reword, or for the result of a squash group (set on its last line).
#[serde(default)]
pub message: Option<String>,
}
/// `git reset` modes offered by Reset Current Branch to Here.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ResetMode {
Soft,
Mixed,
Hard,
Keep,
}
/// A change list on the shelf.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ShelvedChangeList {
/// Folder name under the shelf directory.
pub id: String,
pub name: String,
pub time_ms: f64,
/// Branch checked out when the changes were shelved.
pub branch: Option<String>,
/// Repository-relative paths.
pub files: Vec<ChangedFile>,
}
/// One file of a patch (Apply Patch preview).
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PatchFile {
/// Path relative to the repository root (after the change).
pub path: String,
pub old_path: Option<String>,
pub kind: ChangeKind,
pub added: u32,
pub removed: u32,
pub binary: bool,
/// Applies cleanly to the working tree (otherwise a 3-way merge is attempted).
pub applies: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ApplyOutcome {
/// Repository-relative paths changed without conflicts.
pub applied: Vec<String>,
/// Repository-relative paths left with conflicts (index stages for the merge UI).
pub conflicts: Vec<String>,
}
/// How Checkout treats local changes that the switch would overwrite (IDEA's Smart
/// Checkout dialog).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum CheckoutMode {
/// Fail with [`CheckoutOutcome::LocalChanges`] and change nothing.
#[default]
Normal,
/// Stash local changes, switch, and restore them (conflicts are left to resolve).
Smart,
/// Discard the local changes in the way (`git switch --discard-changes`).
Force,
}
/// Checking out remote `origin/x` while local `x` has commits `origin/x` lacks.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DivergedLocal {
/// Rebase local `x` onto `origin/x` (IDEA "Rebase onto Remote").
Rebase,
/// Reset local `x` to `origin/x` (IDEA "Drop Local Commits").
Reset,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CheckoutOptions {
#[serde(default)]
pub mode: CheckoutMode,
/// Required when checking out a remote branch whose local namesake is ahead of it.
#[serde(default)]
pub diverged: Option<DivergedLocal>,
}
/// Result of [`Repo::checkout_with`]; the non-`Done` variants other than `Conflicts`
/// left the repository untouched.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CheckoutOutcome {
Done,
/// These local changes (repository-relative paths) would be overwritten.
LocalChanges {
files: Vec<String>,
},
/// Local branch `local` has commits that remote branch `remote` does not.
LocalBranchAhead {
local: String,
remote: String,
},
/// Switched, but restoring the local changes (Smart Checkout) or the rebase stopped
/// on conflicts in these files.
Conflicts {
files: Vec<String>,
},
}
/// A deleted local branch, enough to restore it (IDEA's "Restore" notification action).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DeletedBranch {
pub name: String,
/// The commit the branch pointed at.
pub tip: String,
/// Its upstream (`origin/x`), restored with it.
pub upstream: Option<String>,
/// It was not fully merged: its own commits are now unreachable from any branch.
pub unmerged: bool,
}
/// Who stands on each side of the conflicts of the operation in progress (merge editor
/// titles, as IDEA labels them).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MergeSides {
/// `merge`, `rebase`, `cherry-pick`, `revert`…; `None` outside an operation.
pub operation: Option<String>,
/// Git's "ours" is not the user's work: during a rebase stage 2 holds the commits
/// rebased onto and stage 3 the user's commit being replayed. [`MergeVersions`] is
/// already swapped; conflict markers in the file are not.
pub reversed: bool,
/// The current branch (for a rebase: the branch being rebased).
pub branch: Option<String>,
/// Short id of the commit being applied (REBASE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD)
/// or merged (MERGE_HEAD).
pub commit: Option<String>,
/// The other branch: the one being merged, or the one rebased onto.
pub other_branch: Option<String>,
}
/// The versions of a conflicted file as the merge editor shows them.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MergeVersions {
pub base: Option<String>,
/// The user's side ("Accept Yours"): git's stage 2, or stage 3 when `sides.reversed`.
pub ours: Option<String>,
pub theirs: Option<String>,
pub sides: MergeSides,
/// Three-way merge of the versions above: non-conflicting changes merged, each
/// conflict left as a diff3 block (yours / base / theirs). `None` when a side is
/// missing (deleted on one side).
pub merged: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Changelist {
pub id: String,
pub name: String,
#[serde(default)]
pub comment: String,
}
/// User change lists of a project and which list each changed file belongs to.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ChangelistState {
pub lists: Vec<Changelist>,
/// Id of the active list (receives new changes).
pub active: String,
/// Project-relative path → list id. Files not listed belong to the active list.
#[serde(default)]
pub files: std::collections::BTreeMap<String, String>,
}
mod blame;
pub mod changelists;
mod cli;
mod compare;
mod diff;
mod history;
mod ignore;
mod index_writes;
mod manage;
mod ops;
mod patch;
mod refs;
mod remotes;
mod repo;
mod roots;
pub use compare::{CompareResult, CompareScope, RepositoryComparison, WorkingTreeChange};
pub mod shelf;
mod status;
pub use index_writes::is_own_index_write;
mod text_encoding;
mod write;
pub use cli::{
GitCommandObserver, detected_git_executable, git_version, set_askpass_env, set_git_command_observer,
set_git_executable,
};
pub use diff::build_partial_patch;
pub use ignore::{IgnoreDestination, add_to_ignore};
pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree};
pub use remotes::Remote;
pub use roots::Repos;
/// Clones `url` into `dest` (which must not exist or be empty), streaming progress.
pub fn clone(url: &str, dest: &Path, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let parent = dest.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
std::fs::create_dir_all(parent)?;
let dest = if dest.is_absolute() { dest.to_path_buf() } else { std::env::current_dir()?.join(dest) };
let dest = dest.to_string_lossy();
// Cloning must not inherit an enclosing project's executable Git settings.
let cwd = tempfile::tempdir()?;
cli::run_streaming(
cwd.path(),
&["-c", "core.hooksPath=", "clone", "--progress", "--", url, &dest],
cancel,
on_output,
)
}
/// Creates an empty repository in `dir` (`git init`).
pub fn init(dir: &Path) -> IdeResult<()> {
cli::run(dir, &["init", "-q"], None).map(|_| ())
}
/// Line hunks of `new_text` against `old_text` (CRLF-normalised, Histogram diff), in the
/// order [`build_partial_patch`] indexes them.
pub fn diff_hunks(old_text: &str, new_text: &str) -> Vec<LineHunk> {
diff::line_hunks(old_text, new_text)
}
/// An open git repository (worktree). Cheap to keep around; all reads re-check the
/// on-disk state, so it never needs to be reopened after external changes.
pub struct Repo {
execution_allowed: std::sync::atomic::AtomicBool,
root: PathBuf,
git_dir: PathBuf,
inner: std::sync::Mutex<repo::Cache>,
ignored: std::sync::RwLock<IgnoredNames>,
}
impl Repo {
/// Discovers the repository containing `path`; `Ok(None)` when not under git.
pub fn discover(path: &Path) -> IdeResult<Option<Self>> {
repo::discover(path)
}
/// The worktree root.
pub fn root(&self) -> &Path {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
&self.root
}
/// Untracked files with one of these names (or under such a folder) are not reported.
pub fn set_ignored_names(&self, names: IgnoredNames) {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
*self.ignored.write().unwrap_or_else(|p| p.into_inner()) = names;
}
/// Staged (HEAD↔index) and unstaged (index↔worktree, incl. untracked) changes,
/// sorted by path. Conflicted files report `unstaged: Conflicted` and no staged kind.
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
status::status(self)
}
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
refs::branch_info(self)
}
/// Current branch (short name; `None` when detached) and HEAD commit, without
/// upstream tracking (cheap enough to call for every repository on each refresh).
pub fn head(&self) -> IdeResult<(Option<String>, Option<String>)> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
refs::head(&self.gix()?)
}
/// Merge / rebase / cherry-pick… in progress (cheap: no ref walking).
pub fn operation(&self) -> IdeResult<Option<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
Ok(refs::operation(&self.gix()?))
}
/// Content of `path` at `rev` (default HEAD); `None` if absent there.
/// `rev` may be any revision git understands, or `":"` / `":0"` for the index.
pub fn file_at(
&self,
path: &Path,
rev: Option<&str>,
encoding: impl Into<TextEncoding>,
) -> IdeResult<Option<String>> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let rel = self.rel_path(path)?;
let repo = self.gix()?;
Ok(repo::blob_at(&repo, &rel, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
}
/// Raw revision bytes, for encoding detection when the working file is absent.
pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<Vec<u8>>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
repo::blob_at(&self.gix()?, &self.rel_path(path)?, rev.unwrap_or("HEAD"))
}
/// Stage selected decoded hunks, retaining the index encoding, BOM and line endings.
pub fn stage_hunks(
&self,
path: &Path,
current: &str,
selected: &[usize],
encoding: impl Into<TextEncoding>,
) -> IdeResult<()> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stage_hunks(self, path, current, selected, encoding)
}
/// Hunks of `current_text` against HEAD (or the index when `against_index`).
///
/// Deletions have `lines == 0` and `start` = the line of the current text that now
/// follows the removed block (equal to the line count when removed at the end).
/// Files absent from the base yield one `Added` hunk; ignored files, binary files and
/// paths outside the worktree yield no hunks.
pub fn line_hunks(
&self,
path: &Path,
current_text: &str,
against_index: bool,
encoding: impl Into<TextEncoding>,
) -> IdeResult<Vec<LineHunk>> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let Ok(rel) = self.rel_path(path) else { return Ok(Vec::new()) };
if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
return Ok(Vec::new());
}
let repo = self.gix()?;
let base = repo::blob_at(&repo, &rel, if against_index { ":0" } else { "HEAD" })?;
match base {
Some(base) if text_encoding::is_binary(&base, encoding) => Ok(Vec::new()),
Some(base) => Ok(diff::line_hunks(&text_encoding::decode(&base, encoding), current_text)),
None => {
if repo::is_ignored(&repo, &rel)? {
return Ok(Vec::new());
}
Ok(diff::all_added(current_text))
}
}
}
/// One entry per line of the current text (`current_text`, or the file on disk).
/// Lines not yet committed carry an all-zero commit id and author "Not Committed Yet".
/// `revision` is the history to blame against (default `HEAD`); the merge view
/// blames each side pane on the branch it belongs to (e.g. `MERGE_HEAD`).
pub fn blame(
&self,
path: &Path,
current_text: Option<&str>,
revision: Option<&str>,
encoding: impl Into<TextEncoding>,
) -> IdeResult<Vec<BlameLine>> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::blame(self, path, current_text, revision, encoding)
}
/// Commits newest-first in topological order (children before parents).
///
/// `branch` is any revision; when absent, HEAD plus all local and remote branches are
/// walked. `path` limits to commits touching the path (file or directory), `author`
/// matches name or email and `text` the message (both case-insensitive substrings).
/// `skip`/`limit` paginate over the filtered result; `limit == 0` means no limit.
/// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: <name>`.
pub fn log(&self, query: &LogQuery) -> IdeResult<Vec<Commit>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
history::log(self, query)
}
/// Files changed by `commit` relative to its first parent (renames detected).
pub fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
history::commit_files(self, commit)
}
/// Combined changes of several commits (oldest first): per file, before the first commit
/// that touched it ↔ after the last one (IDEA's multi-commit Git Log selection).
pub fn commits_changes(&self, commits: &[String]) -> IdeResult<Vec<RevisionChange>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
history::commits_changes(self, commits)
}
/// Local and remote branches; the current branch first, then most recent first.
pub fn branches(&self) -> IdeResult<Vec<Branch>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
refs::branches(self)
}
/// Stash entries, newest (`stash@{0}`) first.
pub fn stashes(&self) -> IdeResult<Vec<Stash>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
refs::stashes(self)
}
/// Unified diff of a file: index↔worktree (`staged == false`, untracked files diff
/// against /dev/null) or HEAD↔index (`staged == true`). Empty when unchanged.
pub fn diff_text(&self, path: &Path, staged: bool, encoding: impl Into<TextEncoding>) -> IdeResult<String> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::diff_text(self, path, staged, encoding)
}
/// `git config --get <key>` (all scopes); `None` when unset.
pub fn config_get(&self, key: &str) -> IdeResult<Option<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let out = cli::run_raw(&self.root, &["config", "--get", key], None)?;
Ok((out.code == Some(0)).then(|| out.stdout_str().trim().to_string()))
}
// ---- writes (git CLI) ----
pub fn stage(&self, paths: &[PathBuf]) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stage(self, paths)
}
pub fn unstage(&self, paths: &[PathBuf]) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::unstage(self, paths)
}
/// Applies a unified-diff patch to the index (partial staging of hunks/lines).
/// Zero-context patches (see [`build_partial_patch`]) are accepted.
pub fn stage_patch<E: Into<TextEncoding>>(
&self,
patch: &str,
encoding: &dyn Fn(&Path) -> IdeResult<E>,
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stage_patch(self, patch, &|path| encoding(path).map(Into::into))
}
/// Discards unstaged changes: tracked paths are restored from the index (staged
/// changes are kept), untracked files and directories are deleted.
pub fn discard(&self, paths: &[PathBuf]) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::discard(self, paths)
}
/// Commits the index; returns the new HEAD commit id. An empty message with
/// `amend` keeps the previous message.
pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult<String> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() };
write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8.into()))
}
/// Commits with the Commit Options popup's choices.
pub fn commit_with<E: Into<TextEncoding>>(
&self,
message: &str,
options: &CommitOptions,
encoding: &dyn Fn(&Path) -> IdeResult<E>,
) -> IdeResult<String> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::commit(self, message, options, &|path| encoding(path).map(Into::into))
}
/// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch
/// (`origin/x`) gets a local tracking branch and any other revision is checked out
/// detached.
pub fn checkout(&self, branch: &str, create: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::checkout(self, branch, create)
}
/// [`Repo::checkout`] as IDEA does it: local changes in the way are reported (or
/// stashed / discarded per `options.mode`), and a remote branch whose local namesake
/// is behind fast-forwards that branch, while one that is ahead is reported (or
/// rebased / reset per `options.diverged`).
pub fn checkout_with(&self, branch: &str, create: bool, options: &CheckoutOptions) -> IdeResult<CheckoutOutcome> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::checkout_with(self, branch, create, options)
}
/// Creates branch `name` at `start` (default HEAD), optionally switching to it. A
/// remote-tracking start point (`origin/x`) becomes the new branch's upstream.
pub fn create_branch(&self, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::create_branch(self, name, start, checkout)
}
/// Undo Commit: resets the current branch to the parent of `commit` (which must be
/// HEAD and not a merge) keeping its changes staged; undoing the initial commit
/// leaves an unborn branch. Returns the undone commit's message.
pub fn undo_commit(&self, commit: &str) -> IdeResult<String> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::undo_commit(self, commit)
}
/// Deletes a local branch (`git branch -d`, `-D` when `force`).
pub fn delete_branch(&self, branch: &str, force: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::delete_branch(self, branch, force)
}
/// Deletes a local branch as IDEA does: without asking, force-deleting one that is not
/// fully merged (reported as `unmerged`), and returning what [`Repo::restore_branch`]
/// needs to undo it.
pub fn delete_branch_restorable(&self, branch: &str) -> IdeResult<DeletedBranch> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::delete_branch_restorable(self, branch)
}
/// Recreates a deleted branch at its old tip, with its upstream.
pub fn restore_branch(&self, deleted: &DeletedBranch) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::restore_branch(self, deleted)
}
/// Renames a local branch (`git branch -m`); its upstream configuration moves with it.
pub fn rename_branch(&self, from: &str, to: &str) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::rename_branch(self, from, to)
}
/// Merges `branch` into HEAD; local changes are stashed and restored around it. On
/// conflicts an error is returned and the repository is left mid-merge
/// (`branch_info().operation == Some("merge")`).
pub fn merge(&self, branch: &str) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::merge(self, branch)
}
/// Rebases the current branch onto `onto`; local changes are stashed and restored.
pub fn rebase(&self, onto: &str) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::rebase(self, onto)
}
/// `keep_index`: changes already staged stay in the index (and the working tree).
pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stash_push(self, message, include_untracked, keep_index)
}
/// `reinstate_index`: also restore what was staged when the stash was made.
pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stash_apply(self, index, pop, reinstate_index)
}
/// Creates and checks out `branch` at the commit the stash was made on, applies the
/// stash there (index included) and drops it.
pub fn stash_branch(&self, index: u32, branch: &str) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stash_branch(self, index, branch)
}
/// Removes `stash@{index}` without applying it.
pub fn stash_drop(&self, index: u32) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stash_drop(self, index)
}
/// Deletes a branch on a remote (`git push <remote> --delete <branch>`); `branch` is the
/// remote-tracking name, e.g. `origin/feature`.
pub fn delete_remote_branch(
&self,
branch: &str,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::delete_remote_branch(self, branch, cancel, on_output)
}
/// Network operations stream progress lines through `on_output`.
/// Fetches all remotes (pruning deleted branches).
pub fn fetch(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
cli::run_streaming(&self.root, &["fetch", "--progress", "--all", "--prune"], cancel, on_output)
}
/// Update Project: pulls the upstream by merge or rebase. Local changes are stashed
/// and restored; the merge mode fast-forwards when possible whatever `pull.ff` says.
pub fn pull(&self, rebase: bool, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let mode: &[&str] = if rebase { &["--rebase"] } else { &["--no-rebase", "--ff"] };
let mut args = vec!["pull", "--progress", "--autostash"];
args.extend_from_slice(mode);
cli::run_streaming(&self.root, &args, cancel, on_output)
}
/// Pushes the current branch. `set_upstream` pushes to the branch's remote (or
/// `origin`) under the same name and records it as upstream.
pub fn push(
&self,
force_with_lease: bool,
set_upstream: bool,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::push(self, force_with_lease, set_upstream, None, cancel, on_output)
}
/// [`Repo::push`] that also pushes tags (Push dialog › Push tags).
pub fn push_with_tags(
&self,
force_with_lease: bool,
set_upstream: bool,
tags: Option<PushTags>,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::push(self, force_with_lease, set_upstream, tags, cancel, on_output)
}
/// Pushes a local branch, or its history up to `revision`, to its upstream (Git Log
/// › Push…, Push All up to Here).
pub fn push_branch(
&self,
branch: &str,
revision: Option<&str>,
force_with_lease: bool,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::push_branch(
self,
branch,
PushBranchOptions { revision, force_with_lease, ..Default::default() },
cancel,
on_output,
)
}
/// [`Repo::push_branch`] to remote branch `target` (None keeps the upstream's name)
/// and/or remote `remote` (None keeps the upstream's remote). The Push dialog's
/// editable target and remote picker.
pub fn push_branch_to(
&self,
branch: &str,
options: PushBranchOptions<'_>,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::push_branch(self, branch, options, cancel, on_output)
}
/// Fast-forwards a local branch that is not checked out to its upstream (Branches ›
/// Update). Fails when the branch has diverged.
pub fn update_branch(&self, branch: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::update_branch(self, branch, cancel, on_output)
}
// ---- in-progress operations, history rewriting, tags ----
/// Continues the merge / rebase / cherry-pick / revert in progress; fails while
/// conflicts are unresolved.
pub fn continue_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::continue_op(self, cancel, on_output)
}
pub fn abort_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::abort_op(self, cancel, on_output)
}
/// Skips the current commit of a rebase / cherry-pick / revert.
pub fn skip_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::skip_op(self, cancel, on_output)
}
/// Cherry-picks `commits` in order (oldest first). Stops mid-way on conflicts.
pub fn cherry_pick(
&self,
commits: &[String],
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::cherry_pick(self, commits, cancel, on_output)
}
/// Reverts `commits` in order (newest first) with git's default messages.
pub fn revert(&self, commits: &[String], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::revert(self, commits, cancel, on_output)
}
/// Interactive rebase of `base..HEAD` (`None`: from the root) following `entries`,
/// oldest first. Local changes are stashed and restored. Stops (leaving the rebase in
/// progress) on conflicts and at `Edit` entries.
pub fn interactive_rebase(
&self,
base: Option<&str>,
entries: &[RebaseEntry],
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::interactive_rebase(self, base, entries, cancel, on_output)
}
/// Moves the current branch to `commit` (`git reset --<mode>`).
pub fn reset(
&self,
commit: &str,
mode: ResetMode,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::reset(self, commit, mode, cancel, on_output)
}
/// Creates tag `name` on `commit` (annotated when `message` is given).
pub fn create_tag(&self, name: &str, commit: &str, message: Option<&str>, force: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::create_tag(self, name, commit, message, force)
}
pub fn tags(&self) -> IdeResult<Vec<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::tags(self)
}
/// Files that differ between revisions `from` and `to`.
pub fn diff_revisions(&self, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_revisions(self, from, to)
}
/// Tracked files whose working-tree content differs from `rev`.
pub fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_with_worktree(self, rev)
}
/// `(repo-relative path, added lines, deleted lines)` of the tracked files whose
/// working-tree content differs from `rev`.
pub fn numstat_with_worktree(&self, rev: &str) -> IdeResult<Vec<(String, u32, u32)>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::numstat_with_worktree(self, rev)
}
// ---- patches and shelf ----
/// Patch of the local changes of `paths` against HEAD (untracked files included).
pub fn create_patch(&self, paths: &[PathBuf], reverse: bool) -> IdeResult<String> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let rel = self.rel_paths(paths)?;
Ok(patch::working_patch(self, &rel, reverse, true)?.0)
}
/// Patch of the changes made by `commits` (oldest first).
pub fn create_commits_patch(&self, commits: &[String], reverse: bool) -> IdeResult<String> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
patch::commits_patch(self, commits, reverse)
}
pub fn patch_preview(&self, patch: &str) -> IdeResult<Vec<PatchFile>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
patch::preview(self, patch)
}
/// Applies a patch to the working tree, 3-way merging when it does not apply cleanly.
pub fn apply_patch(&self, patch: &str) -> IdeResult<ApplyOutcome> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
patch::apply(self, patch)
}
/// Saves the changes of `paths` under `shelf_dir` and reverts them.
pub fn shelve(&self, shelf_dir: &Path, name: &str, paths: &[PathBuf]) -> IdeResult<ShelvedChangeList> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let rel = self.rel_paths(paths)?;
shelf::shelve(self, shelf_dir, name, &rel)
}
pub fn unshelve(&self, shelf_dir: &Path, id: &str, remove: bool) -> IdeResult<ApplyOutcome> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
shelf::unshelve(self, shelf_dir, id, remove)
}
/// Who is on each side of the conflicts of the operation in progress.
pub fn merge_sides(&self) -> IdeResult<MergeSides> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::merge_sides(self)
}
/// The versions of a conflicted file with the user's work as `ours`, as IDEA's merge
/// dialog shows them (sides swapped during a rebase).
pub fn merge_versions(&self, path: &Path, encoding: impl Into<TextEncoding>) -> IdeResult<MergeVersions> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let (base, ours, theirs) = self.conflict_versions(path, encoding)?;
let sides = self.merge_sides()?;
let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) };
let merged = match (&ours, &theirs) {
(Some(o), Some(t)) => Some(ops::merge_file(self, o, base.as_deref().unwrap_or(""), t)?),
_ => None,
};
Ok(MergeVersions { base, ours, theirs, sides, merged })
}
/// For conflict resolution: (base, ours, theirs) versions of a conflicted file.
pub fn conflict_versions(
&self,
path: &Path,
encoding: impl Into<TextEncoding>,
) -> IdeResult<(Option<String>, Option<String>, Option<String>)> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let rel = self.rel_path(path)?;
let repo = self.gix()?;
let stage = |n: u8| -> IdeResult<Option<String>> {
Ok(repo::blob_at(&repo, &rel, &format!(":{n}"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
};
Ok((stage(1)?, stage(2)?, stage(3)?))
}
}
thread_local! { static EXECUTION_ALLOWED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) }; }
struct ExecutionScope(bool);
impl ExecutionScope {
fn enter(allowed: bool) -> Self {
Self(EXECUTION_ALLOWED.with(|current| current.replace(current.get() && allowed)))
}
}
impl Drop for ExecutionScope {
fn drop(&mut self) {
EXECUTION_ALLOWED.with(|current| current.set(self.0));
}
}
pub(crate) fn execution_allowed() -> bool {
EXECUTION_ALLOWED.with(|current| current.get())
}
impl Repo {
pub fn set_execution_allowed(&self, allowed: bool) {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
self.execution_allowed.store(allowed, std::sync::atomic::Ordering::Release);
}
}