Файли
openidea/crates/ide-vcs/src/lib.rs
T
Shohruh Ismoilovтаshohruh-genius 9298be8798 fix(vcs): allow explicitly overwriting a local branch (#1851)
The New Branch dialog refused any existing local name, so a branch could not be moved to a selected commit. When the name matches a local branch of the target repository, the dialog now offers an unchecked Overwrite existing branch option (git switch -C / git branch --force); the consent clears when the name changes, and Git's checkout checks keep the old tip and local changes if switching fails. New Branch from a Git Log commit also targets that commit's repository in a multi-repository project.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-07 10:36:15 +08:00

1511 рядки
66 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, IdeError, 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,
}
/// History for Selection: the commits that changed a block of lines, newest first.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BlockHistory {
/// The selected lines in HEAD (0-based, inclusive); None when none of them is committed.
pub head_lines: Option<(u32, u32)>,
pub revisions: Vec<BlockRevision>,
}
/// A commit that changed the block, with the block in its first parent and in it.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BlockRevision {
pub id: String,
pub short_id: String,
pub author: String,
pub email: String,
pub time_ms: f64,
pub summary: String,
/// Repository-relative path of the file in this commit (before a later rename).
pub path: String,
/// The block before the commit; empty when the commit added it.
pub before: String,
pub after: String,
/// 0-based first line of the block before and after the commit.
pub before_line: u32,
pub after_line: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Commit {
pub id: String,
pub short_id: String,
pub parents: Vec<String>,
/// Connections in the filtered graph; never use these as revision/diff parents.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_parents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_dashed: Vec<bool>,
/// Repository-relative filename at this revision in rename-following history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_position: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_total: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_boundary_parents: Vec<String>,
/// Reachability from HEAD, computed independently of the visible filters.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_current_branch: Option<bool>,
pub author: String,
pub email: String,
#[serde(default)]
pub committer: String,
#[serde(default)]
pub committer_email: String,
pub time_ms: f64,
pub summary: String,
pub message: String,
#[serde(default)]
pub message_truncated: bool,
pub refs: Vec<String>,
/// Committer time (Log › View Options › Commit Timestamp).
#[serde(default)]
pub commit_time_ms: f64,
/// Legacy alias of `graph_dashed`, indexed by displayed parents.
#[serde(default)]
#[specta(optional)]
pub dashed: Vec<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogDetails {
pub message: String,
pub containing_branches: Vec<String>,
/// Git's verification code: G/U good, B bad, E error, N unsigned, X/Y expired, R revoked.
pub signature: String,
pub signer: String,
pub key: String,
pub fingerprint: String,
}
/// 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,
// Topological grouping with incoming merge parents visited before the main line.
Standard,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogQuery {
#[serde(default)]
pub speed_search: Option<String>,
#[serde(default)]
pub search_fields: Vec<String>,
/// Jump directly to an indexed window containing this commit (50 rows of context).
#[serde(default)]
pub seek: Option<String>,
#[serde(default)]
pub retain_until: Option<String>,
pub branch: Option<String>,
pub path: Option<ide_core::IpcPath>,
#[serde(default)]
pub paths: Vec<ide_core::IpcPath>,
/// File history follows renames; structure filters retain fixed paths.
#[serde(default)]
pub follow: bool,
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>,
/// Additional ranges unioned with each other and with selected branches.
#[serde(default)]
pub ranges: Vec<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 authors_exact: bool,
#[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>,
#[serde(default)]
pub request: Option<ide_core::RequestId>,
#[serde(default)]
pub summary_only: bool,
/// Inclusive refresh boundary. Clients join only verified linear prefixes.
#[serde(default)]
pub stop_at: Option<String>,
}
#[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>,
/// Files committed with their working-tree content, staged or not.
#[serde(default)]
pub stage: Vec<ide_core::IpcPath>,
/// Staged files left out of the commit; they stay staged afterwards.
#[serde(default)]
pub exclude: Vec<ide_core::IpcPath>,
}
/// 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>,
}
/// Added and deleted lines of one file between two revisions (`git diff --numstat`).
/// A rename keeps the counts of the content change; a pure rename is `0`/`0`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileLineStats {
/// Path after the change (the only path, when the file was not renamed).
pub path: String,
pub old_path: Option<String>,
pub added: u32,
pub removed: u32,
}
/// 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,
/// `git push --force` (Force push anyway after a rejected force-with-lease push).
pub force: bool,
/// Record the target as the branch's upstream even when it already has one.
pub set_upstream: bool,
/// Skip the pre-push hook (`--no-verify`).
pub no_verify: bool,
}
/// Options of [`Repo::push_with`]: the Push dialog's choices for the current branch.
#[derive(Clone, Copy, Debug, Default)]
pub struct PushOptions {
/// `git push --force-with-lease`.
pub force_with_lease: bool,
/// `git push --force` (Force push anyway after a rejected force-with-lease push).
pub force: bool,
/// Push to the branch's remote under the same name and record it as upstream.
pub set_upstream: bool,
/// Push tags too.
pub tags: Option<PushTags>,
/// Skip the pre-push hook (`--no-verify`).
pub no_verify: 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 owning the patch; absent in legacy shelves.
#[serde(default)]
pub repo: Option<ide_core::IpcPath>,
/// 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>,
},
/// These untracked files (repository-relative paths) would be overwritten.
UntrackedFiles {
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>,
},
}
/// HEAD before and after a history rewrite, for its Undo.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HeadChange {
pub before: String,
pub after: 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,
}
/// A deleted tag, enough to restore it (Restore in the tag-deleted notification).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DeletedTag {
pub name: String,
/// What the tag pointed at: its tag object (annotated tags) or commit.
pub target: String,
}
/// 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 bek;
mod blame;
mod block_history;
pub mod changelists;
mod cli;
mod compare;
mod diff;
mod history;
mod ignore;
mod index_writes;
mod log_index;
mod manage;
mod ops;
mod patch;
mod preserve;
pub use ops::UNSTAGED_CHANGES_PREVENT_REBASE;
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;
fn clone_args(url: &str, dest: &str, depth: Option<u32>) -> IdeResult<Vec<String>> {
if depth == Some(0) {
return Err(IdeError::invalid("clone depth must be greater than zero"));
}
let mut args = vec!["-c".into(), "core.hooksPath=".into(), "clone".into(), "--progress".into()];
if let Some(depth) = depth {
args.extend(["--depth".into(), depth.to_string()]);
}
args.extend(["--".into(), url.into(), dest.into()]);
Ok(args)
}
/// Clones `url` into `dest` (which must not exist or be empty), streaming progress.
/// `depth` creates a shallow clone containing at most that many commits.
pub fn clone(
url: &str,
dest: &Path,
depth: Option<u32>,
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()?;
let args = clone_args(url, &dest, depth)?;
let args: Vec<&str> = args.iter().map(String::as_str).collect();
cli::run_streaming(cwd.path(), &args, cancel, on_output)
}
#[cfg(test)]
mod clone_tests {
use super::clone_args;
#[test]
fn builds_full_and_shallow_clone_commands() {
assert_eq!(
clone_args("https://example.test/repo.git", "/tmp/repo", None).unwrap(),
["-c", "core.hooksPath=", "clone", "--progress", "--", "https://example.test/repo.git", "/tmp/repo"]
);
assert_eq!(
clone_args("https://example.test/repo.git", "/tmp/repo", Some(3)).unwrap(),
[
"-c",
"core.hooksPath=",
"clone",
"--progress",
"--depth",
"3",
"--",
"https://example.test/repo.git",
"/tmp/repo",
]
);
assert!(clone_args("https://example.test/repo.git", "/tmp/repo", Some(0)).is_err());
}
}
/// 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>,
log_index: std::sync::Mutex<log_index::LogIndex>,
log_signatures: std::sync::Mutex<std::collections::HashMap<String, LogDetails>>,
}
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)
}
/// History for Selection: commits that changed `lines` (0-based, inclusive, of the
/// current text: `current_text`, or the file on disk), mapped to HEAD's lines first.
pub fn block_history(
&self,
path: &Path,
current_text: Option<&str>,
lines: (u32, u32),
encoding: impl Into<TextEncoding>,
cancel: &CancellationToken,
) -> IdeResult<BlockHistory> {
let encoding = encoding.into();
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
block_history::block_history(self, path, current_text, lines, encoding, cancel)
}
/// 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, &CancellationToken::new())
}
pub fn log_cancellable(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
history::log(self, query, cancel)
}
/// Enables the disposable, content-addressed log cache outside the worktree.
pub fn enable_log_index(&self, cache_dir: &Path) {
let key = blake3::hash(self.git_dir.to_string_lossy().as_bytes()).to_hex().to_string();
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).open(&cache_dir.join(key));
}
/// Reference names and peeled commit ids, including tags and detached HEAD.
pub fn log_refs(&self) -> IdeResult<std::collections::BTreeMap<String, String>> {
let repo = self.gix()?;
Ok(history::decorations(&repo)?
.into_iter()
.flat_map(|(id, names)| names.into_iter().map(move |name| (name, id.to_string())))
.collect())
}
pub fn log_users(&self) -> IdeResult<Vec<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let out = cli::run(self.root(), &["log", "--all", "--format=%an <%ae>"], None)?;
Ok(out
.stdout_str()
.lines()
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect())
}
pub fn log_details(&self, revision: &str) -> IdeResult<LogDetails> {
self.log_details_cancellable(revision, &CancellationToken::new())
}
pub fn log_details_cancellable(&self, revision: &str, cancel: &CancellationToken) -> IdeResult<LogDetails> {
let allowed = self.execution_allowed.load(std::sync::atomic::Ordering::Acquire);
let _scope = crate::ExecutionScope::enter(allowed);
let repo = self.gix()?;
let object =
repo.find_object(repo::resolve(&repo, revision)?).map_err(repo::gx)?.peel_to_commit().map_err(repo::gx)?;
let commit_id = object.id;
let id = commit_id.to_string();
let contains = format!("--contains={id}");
let branches =
cli::run_cancellable(self.root(), &["branch", "-a", &contains, refs::DIRECT_BRANCH_FORMAT], cancel)?;
let cache_key = format!("{allowed}:{id}");
let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned();
let mut details = if let Some(details) = cached {
details
} else {
let end = object.data.windows(2).position(|s| s == b"\n\n").unwrap_or(object.data.len());
let headers = &object.data[..end];
let signed = headers.windows(8).any(|s| s == b"\ngpgsig ") || headers.windows(8).any(|s| s == b"\ngpgsig-");
let output = if allowed && signed {
Some(cli::run_cancellable(
self.root(),
&["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", "--end-of-options", &id],
cancel,
)?)
} else {
None
};
let text = output.as_ref().map(|out| out.stdout_str()).unwrap_or_default();
let fields: Vec<_> = text.trim_end().split('\0').collect();
let message = {
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let message = index.message(&repo, commit_id)?;
index.flush();
message
};
let details = LogDetails {
message,
containing_branches: Vec::new(),
signature: if !signed {
"N".into()
} else {
fields.first().filter(|s| !s.is_empty()).unwrap_or(&"E").to_string()
},
signer: fields.get(1).unwrap_or(&"").to_string(),
key: fields.get(2).unwrap_or(&"").to_string(),
fingerprint: fields.get(3).unwrap_or(&"").to_string(),
};
self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone());
details
};
details.containing_branches =
branches.stdout_str().lines().filter(|s| !s.is_empty()).map(str::to_string).collect();
Ok(details)
}
/// Lists one immutable revision directory. Directory names end in `/`.
pub fn revision_children(&self, revision: &str, directory: &str) -> IdeResult<Vec<String>> {
let repo = self.gix()?;
let id = repo::resolve(&repo, revision)?.to_string();
let directory = self.rel_path(std::path::Path::new(directory))?;
let tree = if directory.is_empty() { id } else { format!("{id}:{directory}") };
let out = cli::run(self.root(), &["ls-tree", "-z", "--", &tree], None)?;
Ok(out
.stdout
.split(|b| *b == 0)
.filter_map(|line| {
let split = line.iter().position(|b| *b == b'\t')?;
let name = String::from_utf8_lossy(&line[split + 1..]);
let dir = line.starts_with(b"040000 ");
Some(format!(
"{}{name}{}",
if directory.is_empty() { String::new() } else { format!("{directory}/") },
if dir { "/" } else { "" }
))
})
.collect())
}
pub fn revision_files(&self, revision: &str) -> IdeResult<Vec<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let repo = self.gix()?;
let id = repo::resolve(&repo, revision)?.to_string();
let out = cli::run(self.root(), &["ls-tree", "-r", "-z", "--name-only", "--", &id], None)?;
Ok(out
.stdout
.split(|b| *b == 0)
.filter(|p| !p.is_empty())
.map(|p| String::from_utf8_lossy(p).into_owned())
.collect())
}
/// 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, ..Default::default() };
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<()> {
self.create_branch_with_overwrite(name, start, checkout, false)
}
/// Creates a branch or moves its tip when overwrite is explicitly enabled.
pub fn create_branch_with_overwrite(
&self,
name: &str,
start: Option<&str>,
checkout: bool,
overwrite: bool,
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::create_branch(self, name, start, checkout, overwrite)
}
/// 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`).
/// Edit Commit Message without touching the index or the working tree; returns HEAD
/// before and after.
pub fn reword(&self, commit: &str, message: &str) -> IdeResult<HeadChange> {
write::reword(self, commit, message).map(|(before, after)| HeadChange { before, after })
}
/// Undoes a history rewrite: HEAD goes from `after` back to `before`, local changes kept.
pub fn restore_head(&self, before: &str, after: &str) -> IdeResult<()> {
write::restore_head(self, before, after)
}
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"];
args.extend_from_slice(mode);
preserve::around(self, "Local changes saved before update", || {
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<()> {
self.push_with(PushOptions { force_with_lease, set_upstream, ..Default::default() }, 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<()> {
self.push_with(PushOptions { force_with_lease, set_upstream, tags, ..Default::default() }, cancel, on_output)
}
/// Pushes the current branch with the Push dialog's options.
pub fn push_with(
&self,
options: PushOptions,
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, options, cancel, on_output)
}
/// Whether the repository has an executable hook `name` (e.g. `pre-push`), wherever
/// `core.hooksPath` puts it: the Push dialog then offers to skip it.
pub fn has_hook(&self, name: &str) -> IdeResult<bool> {
write::has_hook(self, name)
}
/// 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)
}
/// Update Project › Reset to the Remote Branch: the current branch is moved to its
/// freshly fetched tracked branch; local commits are dropped, local changes kept.
pub fn reset_to_upstream(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::reset_to_upstream(self, 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, false, cancel, on_output)
}
/// Stage and Retry: stages the changes of tracked files, then continues.
pub fn stage_and_continue(&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, true, 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<()> {
self.cherry_pick_with(commits, false, cancel, on_output)
}
/// [`Repo::cherry_pick`]; `record_origin` adds "(cherry picked from commit …)" (`-x`).
pub fn cherry_pick_with(
&self,
commits: &[String],
record_origin: bool,
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, record_origin, 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)
}
/// Deletes a local tag, returning what [`Repo::restore_tag`] needs to undo it.
pub fn delete_tag(&self, name: &str) -> IdeResult<DeletedTag> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::delete_tag(self, name)
}
/// Recreates a deleted tag (annotated ones with their message).
pub fn restore_tag(&self, deleted: &DeletedTag) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::restore_tag(self, deleted)
}
/// Pushes one tag to `remote` (Push Tag › Push to …).
pub fn push_tag(
&self,
tag: &str,
remote: &str,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::push_tag(self, tag, remote, false, cancel, on_output)
}
/// Deletes a tag on `remote` (Delete on Remote, after deleting it locally).
pub fn delete_remote_tag(
&self,
tag: &str,
remote: &str,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::push_tag(self, tag, remote, true, cancel, on_output)
}
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)
}
/// Added and deleted lines between `from` and `to`. `paths` limits the diff to those
/// repository-relative paths (both names of a rename); empty diffs every file.
pub fn diff_numstat(&self, from: &str, to: &str, paths: &[String]) -> IdeResult<Vec<FileLineStats>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_numstat(self, from, to, paths)
}
/// 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);
}
}