mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-12 18:36:53 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a85d49617 | ||
|
|
b7ba9d7391 |
No files matched your search
Generated
+14
@@ -4691,6 +4691,20 @@ dependencies = [
|
||||
"zeromq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-p4"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.43"
|
||||
|
||||
@@ -24,6 +24,7 @@ ide-jsonrpc = { path = "crates/ide-jsonrpc" }
|
||||
ide-lsp = { path = "crates/ide-lsp" }
|
||||
ide-dap = { path = "crates/ide-dap" }
|
||||
ide-vcs = { path = "crates/ide-vcs" }
|
||||
ide-p4 = { path = "crates/ide-p4" }
|
||||
ide-terminal = { path = "crates/ide-terminal" }
|
||||
ide-run = { path = "crates/ide-run" }
|
||||
ide-workspace = { path = "crates/ide-workspace" }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "ide-p4"
|
||||
description = "Perforce (Helix Core / P4) integration built on the installed p4 CLI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tracing.workspace = true
|
||||
which = "8"
|
||||
regex-lite = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Thin wrapper around the `p4` executable used for every operation.
|
||||
//!
|
||||
//! Like the Git backend, every Perforce call goes through the installed CLI so
|
||||
//! `P4CONFIG`, ticket authentication and connection settings behave exactly as
|
||||
//! on the command line. `-ztag` keeps output machine-readable for the parsers
|
||||
//! in [`crate::opened`] and [`crate::status`].
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::RwLock;
|
||||
|
||||
static P4_EXECUTABLE: RwLock<Option<PathBuf>> = RwLock::new(None);
|
||||
|
||||
/// Explicit `p4` executable from Settings › Version Control › Perforce
|
||||
/// (`None` = auto-detect). Set once when settings load.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_p4_executable(path: Option<&Path>) {
|
||||
*P4_EXECUTABLE.write().unwrap_or_else(|p| p.into_inner()) = path.map(|p| p.to_path_buf());
|
||||
}
|
||||
|
||||
/// The configured or detected `p4` executable.
|
||||
pub fn p4(exe: Option<&Path>) -> IdeResult<PathBuf> {
|
||||
if let Some(p) = exe {
|
||||
return Ok(p.to_path_buf());
|
||||
}
|
||||
if let Some(p) = P4_EXECUTABLE.read().unwrap_or_else(|p| p.into_inner()).clone() {
|
||||
return Ok(p);
|
||||
}
|
||||
crate::detected_p4_executable().ok_or_else(|| IdeError::missing_tool("p4"))
|
||||
}
|
||||
|
||||
/// Runs `p4 args` in `cwd`, returning stdout. Stderr is folded into the error
|
||||
/// message; `p4` prints diagnostics there for a nonzero exit. Connection and
|
||||
/// authentication failures surface as `Unavailable` so the UI can offer to
|
||||
/// configure the server.
|
||||
pub fn run(exe: &Path, args: &[&str], cwd: Option<&Path>) -> IdeResult<String> {
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.args(args);
|
||||
if let Some(cwd) = cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let out = cmd.output().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound { IdeError::missing_tool("p4") } else { IdeError::from(e) }
|
||||
})?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if !out.status.success() {
|
||||
let msg = stderr.trim().to_string();
|
||||
let msg = if msg.is_empty() { stdout.trim().to_string() } else { msg };
|
||||
return Err(classify(&msg));
|
||||
}
|
||||
// `p4` can still report a recoverable error on stdout with a zero exit
|
||||
// (e.g. "… - file(s) not on client").
|
||||
if stderr.trim().is_empty() {
|
||||
return Ok(stdout.into_owned());
|
||||
}
|
||||
let warn = stderr.trim();
|
||||
if warn.starts_with("warning:") {
|
||||
tracing::warn!(target: "p4", "{warn}");
|
||||
return Ok(stdout.into_owned());
|
||||
}
|
||||
Err(classify(warn))
|
||||
}
|
||||
|
||||
fn classify(msg: &str) -> IdeError {
|
||||
let m = msg.to_lowercase();
|
||||
if m.contains("connect to server failed")
|
||||
|| m.contains("can't connect")
|
||||
|| m.contains("operation timed out")
|
||||
|| m.contains("host unknown")
|
||||
{
|
||||
return IdeError::unavailable(msg);
|
||||
}
|
||||
if m.contains("please login")
|
||||
|| m.contains("ticket") && m.contains("expired")
|
||||
|| m.contains("password") && m.contains("required")
|
||||
{
|
||||
return IdeError::unavailable(format!("Perforce login required: {msg}"));
|
||||
}
|
||||
if m.contains("not on client") || m.contains("no such file") || m.contains("not in client view") {
|
||||
return IdeError::not_found(msg);
|
||||
}
|
||||
if m.contains("can't edit") && m.contains("opened") || m.contains("also opened by") {
|
||||
return IdeError::new(ide_core::error::ErrorKind::AlreadyExists, msg);
|
||||
}
|
||||
IdeError::internal(msg)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! The Perforce client: connection settings and the write operations
|
||||
//! (`edit`/`add`/`delete`/`revert`/`sync`/`submit`) plus `changelist` handling.
|
||||
//!
|
||||
//! Field names follow `p4 -ztag` output: a tagged record is a set of
|
||||
//! `... <name> <value>` lines terminated by a blank line, and `code`/`severity`
|
||||
//! mark informational vs error records.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::cli;
|
||||
|
||||
/// Server, user and client workspace of a `p4 info` — everything the settings
|
||||
/// page needs to configure and confirm a connection.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Connection {
|
||||
/// `P4PORT` the client talks to (`ssl:host:port` when TLS).
|
||||
pub server_address: String,
|
||||
/// `P4USER`.
|
||||
pub user_name: String,
|
||||
/// `P4CLIENT`, the workspace spec name.
|
||||
pub client_name: String,
|
||||
/// Client root on disk (the local top folder of the view).
|
||||
pub client_root: String,
|
||||
/// The host the client spec is bound to, when set (`clientHost`).
|
||||
pub client_host: Option<String>,
|
||||
/// Server version string from `p4 info` (`serverVersion`).
|
||||
pub server_version: Option<String>,
|
||||
}
|
||||
|
||||
/// A file opened for a Perforce action (`p4 opened`).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenedFile {
|
||||
/// Local path, resolved against the client root.
|
||||
pub path: ide_core::IpcPath,
|
||||
/// `depotFile` (`//depot/…`).
|
||||
pub depot_file: String,
|
||||
/// `rev` (`#3`) without the `#`.
|
||||
pub rev: Option<u32>,
|
||||
/// `action` — the open operation.
|
||||
pub action: ChangeAction,
|
||||
/// `change` — the changelist number, `0` for `default`.
|
||||
pub change: u32,
|
||||
/// `type` (`text`, `binary`, …).
|
||||
pub file_type: Option<String>,
|
||||
/// Locked by this client (`ourLock`) or another (`otherLock`).
|
||||
pub locked: bool,
|
||||
/// Other users/clients holding the file open (`otherOpen`).
|
||||
pub other_open: Vec<String>,
|
||||
}
|
||||
|
||||
/// What an opened file is being changed for (`p4 opened` / `p4 fstat action`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChangeAction {
|
||||
Edit,
|
||||
Add,
|
||||
Delete,
|
||||
Branch,
|
||||
Integrate,
|
||||
MoveAdd,
|
||||
MoveDelete,
|
||||
/// `archive`/`purge` or an action not on this list.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ChangeAction {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"edit" => Self::Edit,
|
||||
"add" => Self::Add,
|
||||
"delete" => Self::Delete,
|
||||
"branch" => Self::Branch,
|
||||
"integrate" => Self::Integrate,
|
||||
"move/add" => Self::MoveAdd,
|
||||
"move/delete" => Self::MoveDelete,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Perforce client workspace rooted at `root`, bound to `conn`'s settings.
|
||||
/// Method calls run `p4` with `-c client -u user -p port` so a `Client` can
|
||||
/// point at a non-default workspace.
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
pub root: PathBuf,
|
||||
pub conn: Connection,
|
||||
exe: PathBuf,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Binds `exe` (`None` = auto-detect) to the workspace `conn` describes.
|
||||
pub fn new(conn: Connection, exe: Option<&Path>) -> IdeResult<Self> {
|
||||
let exe = cli::p4(exe)?;
|
||||
let root = PathBuf::from(&conn.client_root);
|
||||
Ok(Self { root, conn, exe })
|
||||
}
|
||||
|
||||
/// `p4 info` in `cwd`: the connection the workspace `cwd` belongs to, or
|
||||
/// `Ok(None)` when `cwd` is not inside a Perforce client.
|
||||
pub fn info(exe: Option<&Path>, cwd: &Path) -> IdeResult<Option<Connection>> {
|
||||
let exe = cli::p4(exe)?;
|
||||
let out = match cli::run(&exe, &["-ztag", "info"], Some(cwd)) {
|
||||
Ok(o) => o,
|
||||
Err(e) if matches!(e.kind, ide_core::error::ErrorKind::Unavailable) => return Err(e),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
Ok(Some(parse_info(&out)))
|
||||
}
|
||||
|
||||
fn args(&self, cmd: &str, extra: &[&str]) -> Vec<String> {
|
||||
let mut a = vec![
|
||||
"-ztag".to_string(),
|
||||
"-p".to_string(),
|
||||
self.conn.server_address.clone(),
|
||||
"-u".to_string(),
|
||||
self.conn.user_name.clone(),
|
||||
"-c".to_string(),
|
||||
self.conn.client_name.clone(),
|
||||
cmd.to_string(),
|
||||
];
|
||||
a.extend(extra.iter().map(|s| s.to_string()));
|
||||
a
|
||||
}
|
||||
|
||||
fn run(&self, cmd: &str, extra: &[&str]) -> IdeResult<String> {
|
||||
let args = self.args(cmd, extra);
|
||||
cli::run(&self.exe, &args.iter().map(|s| s.as_str()).collect::<Vec<_>>(), Some(&self.root))
|
||||
}
|
||||
|
||||
/// `p4 opened` of `paths` (`//depot/…` or local paths; `[]` = the whole client).
|
||||
pub fn opened(&self, paths: &[&str]) -> IdeResult<Vec<OpenedFile>> {
|
||||
let mut extra = vec!["opened"];
|
||||
extra.extend_from_slice(paths);
|
||||
let out = self.run("opened", &extra[1..])?;
|
||||
Ok(crate::opened::parse(&out, &self.conn.client_root))
|
||||
}
|
||||
|
||||
/// `p4 fstat` of `paths`: depot metadata for opened checks and status.
|
||||
pub fn fstat(&self, paths: &[&str]) -> IdeResult<Vec<OpenedFile>> {
|
||||
let out = self.run("fstat", paths)?;
|
||||
Ok(crate::opened::parse_fstat(&out, &self.conn.client_root))
|
||||
}
|
||||
|
||||
/// `p4 edit` — open for edit, `-c change` when not default.
|
||||
pub fn edit(&self, paths: &[&str], change: Option<u32>) -> IdeResult<()> {
|
||||
self.open("edit", paths, change)
|
||||
}
|
||||
|
||||
/// `p4 add` — open for add.
|
||||
pub fn add(&self, paths: &[&str], change: Option<u32>) -> IdeResult<()> {
|
||||
self.open("add", paths, change)
|
||||
}
|
||||
|
||||
/// `p4 delete` — open for delete.
|
||||
pub fn delete(&self, paths: &[&str], change: Option<u32>) -> IdeResult<()> {
|
||||
self.open("delete", paths, change)
|
||||
}
|
||||
|
||||
fn open(&self, action: &str, paths: &[&str], change: Option<u32>) -> IdeResult<()> {
|
||||
let mut extra: Vec<String> = Vec::new();
|
||||
if let Some(c) = change.filter(|c| *c != 0) {
|
||||
extra.push("-c".to_string());
|
||||
extra.push(c.to_string());
|
||||
}
|
||||
extra.extend(paths.iter().map(|s| s.to_string()));
|
||||
let out = self.run(action, &extra.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
|
||||
// `p4 edit` succeeds even when a file is already opened or "not on
|
||||
// client"; report those lines so callers don't read silence as success.
|
||||
let problems = out
|
||||
.lines()
|
||||
.filter(|l| l.contains("can't") || l.contains("not on client") || l.contains("not in client view"))
|
||||
.collect::<Vec<_>>();
|
||||
if !problems.is_empty() {
|
||||
return Err(IdeError::invalid(problems.join("\n")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `p4 revert` — restore files to the depot revision, `-a` reverts
|
||||
/// unchanged files only.
|
||||
pub fn revert(&self, paths: &[&str], unchanged_only: bool) -> IdeResult<()> {
|
||||
let mut extra = Vec::new();
|
||||
if unchanged_only {
|
||||
extra.push("-a");
|
||||
}
|
||||
extra.extend_from_slice(paths);
|
||||
self.run("revert", &extra).map(|_| ())
|
||||
}
|
||||
|
||||
/// `p4 sync` — bring the workspace to `paths`' head revisions.
|
||||
pub fn sync(&self, paths: &[&str], force: bool) -> IdeResult<String> {
|
||||
let mut extra = Vec::new();
|
||||
if force {
|
||||
extra.push("-f");
|
||||
}
|
||||
extra.extend_from_slice(paths);
|
||||
self.run("sync", &extra)
|
||||
}
|
||||
|
||||
/// `p4 describe -s change`: files and the description of a submitted or
|
||||
/// pending changelist.
|
||||
pub fn describe(&self, change: u32) -> IdeResult<String> {
|
||||
self.run("describe", &["-s", &change.to_string()])
|
||||
}
|
||||
|
||||
/// `p4 changes -m N -s pending -c client` — the client's pending changelists.
|
||||
pub fn changelists(&self, max: u32) -> IdeResult<Vec<P4Changelist>> {
|
||||
let out = self.run("changes", &["-m", &max.to_string(), "-s", "pending", "-c", &self.conn.client_name])?;
|
||||
Ok(parse_changelists(&out))
|
||||
}
|
||||
|
||||
/// `p4 submit -d "desc"` — submits the default or `-c change` list.
|
||||
pub fn submit(&self, description: &str, change: Option<u32>) -> IdeResult<String> {
|
||||
let mut extra = vec!["-d".to_string(), description.to_string()];
|
||||
if let Some(c) = change.filter(|c| *c != 0) {
|
||||
extra.push("-c".to_string());
|
||||
extra.push(c.to_string());
|
||||
}
|
||||
self.run("submit", &extra.iter().map(|s| s.as_str()).collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending changelist summary (`p4 changes`).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct P4Changelist {
|
||||
pub change: u32,
|
||||
pub user: String,
|
||||
pub client: String,
|
||||
/// First line of the description.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// `p4 -ztag info` → [`Connection`]. Field names are those `info` emits:
|
||||
/// `serverAddress`, `userName`, `clientName`, `clientRoot`, `clientHost`,
|
||||
/// `serverVersion`.
|
||||
fn parse_info(out: &str) -> Connection {
|
||||
let field = |name: &str| -> Option<String> {
|
||||
out.lines()
|
||||
.find_map(|l| l.strip_prefix(&format!("... {name} ")).map(str::to_string))
|
||||
.or_else(|| out.lines().find_map(|l| l.strip_prefix(&format!("{name}: ")).map(str::to_string)))
|
||||
};
|
||||
Connection {
|
||||
server_address: field("serverAddress").unwrap_or_default(),
|
||||
user_name: field("userName").unwrap_or_default(),
|
||||
client_name: field("clientName").unwrap_or_default(),
|
||||
client_root: field("clientRoot").unwrap_or_default(),
|
||||
client_host: field("clientHost"),
|
||||
server_version: field("serverVersion"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `p4 changes -ztag` records: `... change N`, `... user`, `... client`,
|
||||
/// `... desc` (the description's first line, often followed by `...` continuations).
|
||||
fn parse_changelists(out: &str) -> Vec<P4Changelist> {
|
||||
let mut list = Vec::new();
|
||||
let mut cur = P4Changelist { change: 0, user: String::new(), client: String::new(), description: String::new() };
|
||||
let mut seen = false;
|
||||
for line in out.lines() {
|
||||
let Some(f) = line.strip_prefix("... ") else { continue };
|
||||
let (name, value) = f.split_once(' ').map(|(a, b)| (a, b.trim())).unwrap_or((f, ""));
|
||||
match name {
|
||||
"change" => {
|
||||
if seen {
|
||||
list.push(std::mem::replace(
|
||||
&mut cur,
|
||||
P4Changelist {
|
||||
change: 0,
|
||||
user: String::new(),
|
||||
client: String::new(),
|
||||
description: String::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
seen = true;
|
||||
cur.change = value.parse().unwrap_or(0);
|
||||
}
|
||||
"user" => cur.user = value.to_string(),
|
||||
"client" => cur.client = value.to_string(),
|
||||
"desc" if cur.description.is_empty() => cur.description = value.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
list.push(cur);
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_tagged_info() {
|
||||
let out = "... serverAddress ssl:perforce:1666\n... userName build\n... clientName build-ws\n\
|
||||
... clientRoot /ws/build\n... serverVersion P4D/LINUX26X86_64/2024.1\n\n";
|
||||
let c = parse_info(out);
|
||||
assert_eq!(c.server_address, "ssl:perforce:1666");
|
||||
assert_eq!(c.user_name, "build");
|
||||
assert_eq!(c.client_name, "build-ws");
|
||||
assert_eq!(c.client_root, "/ws/build");
|
||||
assert_eq!(c.server_version.as_deref(), Some("P4D/LINUX26X86_64/2024.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_changes() {
|
||||
let out = "... change 12\n... user dev\n... client dev-ws\n... desc 'fix login'\n\n\
|
||||
... change 7\n... user dev\n... client dev-ws\n... desc 'wip'\n\n";
|
||||
let l = parse_changelists(out);
|
||||
assert_eq!(l.len(), 2);
|
||||
assert_eq!(l[0].change, 12);
|
||||
assert_eq!(l[0].description, "'fix login'");
|
||||
assert_eq!(l[1].change, 7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Perforce (Helix Core / P4) integration, built on the installed `p4` CLI.
|
||||
//!
|
||||
//! Reads (`p4 info`, `p4 fstat`, `p4 opened`, `p4 describe`) and writes
|
||||
//! (`p4 edit`, `add`, `delete`, `revert`, `sync`, `submit`, `shelve`) all shell
|
||||
//! out so credentials, tickets and `P4CONFIG` behave exactly as on the command
|
||||
//! line. The [`Client`] is configured like the `p4` binary itself: `P4PORT`,
|
||||
//! `P4USER`, `P4CLIENT` come from the environment or a `p4 set` override.
|
||||
|
||||
mod cli;
|
||||
mod client;
|
||||
mod opened;
|
||||
mod status;
|
||||
|
||||
pub use client::{ChangeAction, Client, Connection, OpenedFile, P4Changelist};
|
||||
pub use status::{P4FileStatus, Workspace};
|
||||
|
||||
use ide_core::IdeResult;
|
||||
use std::path::Path;
|
||||
|
||||
/// The detected `p4` executable, or `None` when it is not on `PATH`.
|
||||
pub fn detected_p4_executable() -> Option<std::path::PathBuf> {
|
||||
which::which("p4").ok().or_else(|| {
|
||||
[
|
||||
r"C:\Program Files\Perforce\p4.exe",
|
||||
r"C:\Program Files (x86)\Perforce\p4.exe",
|
||||
"/usr/local/bin/p4",
|
||||
"/usr/bin/p4",
|
||||
"/opt/perforce/bin/p4",
|
||||
]
|
||||
.iter()
|
||||
.map(std::path::Path::new)
|
||||
.find(|p| p.exists())
|
||||
.map(|p| p.to_path_buf())
|
||||
})
|
||||
}
|
||||
|
||||
/// `p4 -V` of `exe` (`None` = auto-detect), for Settings › Version Control › Perforce.
|
||||
pub fn p4_version(exe: Option<&Path>) -> IdeResult<String> {
|
||||
let exe = cli::p4(exe)?;
|
||||
let out = cli::run(&exe, &["-V"], None)?;
|
||||
Ok(out
|
||||
.lines()
|
||||
.find(|l| l.contains("Rev.") || l.contains("Version"))
|
||||
.map(str::trim)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| out.trim().to_string()))
|
||||
}
|
||||
|
||||
/// The workspace `path` belongs to (`p4 info` walks up for a `P4CONFIG`), or
|
||||
/// `Ok(None)` when `path` is not inside a Perforce client.
|
||||
pub fn discover_workspace(path: &Path) -> IdeResult<Option<Workspace>> {
|
||||
Workspace::discover(path)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! `p4 opened` / `p4 fstat` tagged-output parsing → [`OpenedFile`].
|
||||
//!
|
||||
//! `p4 -ztag` emits one record per file as `... <name> <value>` lines ending in
|
||||
//! a blank line. `opened` reports `depotFile`, `rev`, `action`, `change`,
|
||||
//! `client`, `type` plus lock fields (`ourLock`, `otherLock`, `otherOpen`);
|
||||
//! `fstat` reports the same `depotFile`/`action` plus `clientFile`/`isMapped`
|
||||
//! for files not yet opened.
|
||||
|
||||
use crate::client::{ChangeAction, OpenedFile};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One `-ztag` record's fields.
|
||||
fn records(out: &str) -> Vec<Vec<(String, String)>> {
|
||||
let mut all = Vec::new();
|
||||
let mut cur = Vec::new();
|
||||
for line in out.lines() {
|
||||
if let Some(f) = line.strip_prefix("... ") {
|
||||
let (name, value) = f
|
||||
.split_once(' ')
|
||||
.map(|(a, b)| (a.to_string(), b.to_string()))
|
||||
.unwrap_or((f.to_string(), String::new()));
|
||||
cur.push((name, value));
|
||||
} else if line.trim().is_empty() && !cur.is_empty() {
|
||||
all.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
all.push(cur);
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
fn field<'a>(rec: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||||
rec.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
fn field_all<'a>(rec: &'a [(String, String)], name: &str) -> Vec<&'a str> {
|
||||
rec.iter().filter(|(n, _)| n == name).map(|(_, v)| v.as_str()).collect()
|
||||
}
|
||||
|
||||
/// `p4 opened -ztag` records → opened files, local paths resolved under `root`.
|
||||
pub fn parse(out: &str, root: &str) -> Vec<OpenedFile> {
|
||||
records(out).iter().filter(|rec| field(rec, "depotFile").is_some()).map(|rec| to_opened(rec, root)).collect()
|
||||
}
|
||||
|
||||
/// `p4 fstat -ztag` records → opened files (files with an `action`), plus
|
||||
/// mapped-but-unopened entries when `clientFile` is present.
|
||||
pub fn parse_fstat(out: &str, root: &str) -> Vec<OpenedFile> {
|
||||
records(out)
|
||||
.iter()
|
||||
.filter(|rec| field(rec, "depotFile").is_some() || field(rec, "clientFile").is_some())
|
||||
.map(|rec| to_opened(rec, root))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn to_opened(rec: &[(String, String)], root: &str) -> OpenedFile {
|
||||
let depot = field(rec, "depotFile").unwrap_or_default().to_string();
|
||||
let client_file = field(rec, "clientFile").or_else(|| field(rec, "depotFile")).unwrap_or_default();
|
||||
let path = local_path(root, client_file, &depot);
|
||||
OpenedFile {
|
||||
path: ide_core::IpcPath(path),
|
||||
depot_file: depot,
|
||||
rev: field(rec, "rev").and_then(|r| r.trim_start_matches('#').parse().ok()),
|
||||
action: ChangeAction::parse(field(rec, "action").unwrap_or("")),
|
||||
change: field(rec, "change").and_then(|c| c.parse().ok()).unwrap_or(0),
|
||||
file_type: field(rec, "type").map(str::to_string),
|
||||
locked: field(rec, "ourLock").is_some() || field(rec, "otherLock").is_some(),
|
||||
other_open: field_all(rec, "otherOpen").into_iter().map(str::to_string).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `clientFile` is a local absolute path on the client; `depotFile` is
|
||||
/// `//depot/…` and must be mapped through the client view. Without a view we
|
||||
/// fall back to the depot path minus its `//depot/` prefix under `root`.
|
||||
fn local_path(root: &str, client_file: &str, depot_file: &str) -> PathBuf {
|
||||
let cf = client_file.trim();
|
||||
if !cf.is_empty() && !cf.starts_with("//") {
|
||||
let p = PathBuf::from(cf);
|
||||
if p.is_absolute() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
// depot → client: strip the leading `//<depot>/` and join under root.
|
||||
let rel = depot_file.trim_start_matches("//").split_once('/').map(|(_, r)| r).unwrap_or(depot_file);
|
||||
Path::new(root).join(rel)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_opened_records() {
|
||||
let out = "... depotFile //depot/src/a.rs\n... rev 3\n... action edit\n\
|
||||
... change default\n... type text\n... user dev\n... client dev-ws\n\n\
|
||||
... depotFile //depot/src/b.rs\n... rev 1\n... action add\n\
|
||||
... change 42\n... type text\n... ourLock\n\n";
|
||||
let files = parse(out, "/ws/dev");
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(files[0].depot_file, "//depot/src/a.rs");
|
||||
assert_eq!(files[0].action, ChangeAction::Edit);
|
||||
assert_eq!(files[0].rev, Some(3));
|
||||
assert_eq!(files[1].action, ChangeAction::Add);
|
||||
assert_eq!(files[1].change, 42);
|
||||
assert!(files[1].locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_path_prefers_client_file() {
|
||||
assert_eq!(local_path("/ws", "/ws/src/a.rs", "//depot/src/a.rs"), PathBuf::from("/ws/src/a.rs"));
|
||||
assert_eq!(local_path("/ws", "", "//depot/src/a.rs"), PathBuf::from("/ws/src/a.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fstat_unopened() {
|
||||
let out = "... depotFile //depot/src/c.rs\n... clientFile /ws/src/c.rs\n\
|
||||
... isMapped\n... headRev 5\n... type text\n\n";
|
||||
let files = parse_fstat(out, "/ws");
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].path.0, PathBuf::from("/ws/src/c.rs"));
|
||||
assert_eq!(files[0].action, ChangeAction::Other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Perforce file status: which files are opened, modified against have, or
|
||||
//! untracked, mapped onto the shared [`ide_vcs`]-style status model.
|
||||
//!
|
||||
//! Perforce has no index: a file is *opened* (staged) or it is not, and a
|
||||
//! opened file that differs on disk is also *modified* (unstaged). Untracked is
|
||||
//! a file under the client root that `p4 fstat` reports unmapped — matching
|
||||
//! `FileStatus`'s staged/unstaged split so the VCS tool window can show P4
|
||||
//! changes with the same rows it uses for Git.
|
||||
|
||||
use crate::client::{Client, Connection};
|
||||
use ide_core::IdeResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::client::ChangeAction;
|
||||
|
||||
/// One file's Perforce status, aligned to the Git `FileStatus` shape.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct P4FileStatus {
|
||||
pub path: ide_core::IpcPath,
|
||||
pub rel_path: String,
|
||||
/// Opened to a changelist (`staged` equivalent).
|
||||
pub opened: Option<ChangeAction>,
|
||||
/// Differs from the have revision on disk (`unstaged` equivalent).
|
||||
pub modified: bool,
|
||||
/// Under the client root but not in the depot (`untracked`).
|
||||
pub untracked: bool,
|
||||
/// Changelist the file is opened to (`0` = default).
|
||||
pub change: u32,
|
||||
}
|
||||
|
||||
/// A Perforce workspace (client root + its connection), discovered under a path.
|
||||
#[derive(Clone)]
|
||||
pub struct Workspace {
|
||||
pub root: PathBuf,
|
||||
pub conn: Connection,
|
||||
exe: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// The workspace `path` belongs to (`p4 info`), or `Ok(None)` when `path`
|
||||
/// is not inside a Perforce client root.
|
||||
pub fn discover(path: &Path) -> IdeResult<Option<Self>> {
|
||||
let exe = crate::detected_p4_executable();
|
||||
Self::discover_with(path, exe.as_deref())
|
||||
}
|
||||
|
||||
/// `discover` with an explicit `p4` executable (tests, settings override).
|
||||
pub fn discover_with(path: &Path, exe: Option<&Path>) -> IdeResult<Option<Self>> {
|
||||
let start = if path.is_file() { path.parent().unwrap_or(path) } else { path };
|
||||
let mut dir = start;
|
||||
loop {
|
||||
if let Some(conn) = Client::info(exe, dir)? {
|
||||
if conn.client_root.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let root = PathBuf::from(&conn.client_root);
|
||||
return Ok(Some(Workspace { root, conn, exe: exe.map(|p| p.to_path_buf()) }));
|
||||
}
|
||||
match dir.parent() {
|
||||
Some(p) => dir = p,
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound [`Client`] for write operations.
|
||||
pub fn client(&self) -> IdeResult<Client> {
|
||||
Client::new(self.conn.clone(), self.exe.as_deref())
|
||||
}
|
||||
|
||||
/// Status of every file under `paths` (relative or absolute; `[]` = the
|
||||
/// whole client). Combines `p4 opened` (staged) with `p4 fstat` (modified
|
||||
/// against have) and a walk for untracked files.
|
||||
pub fn status(&self, paths: &[&str]) -> IdeResult<Vec<P4FileStatus>> {
|
||||
let client = self.client()?;
|
||||
let specs: Vec<String> = paths.iter().map(|p| self.to_spec(p)).collect();
|
||||
let specs_ref: Vec<&str> = specs.iter().map(|s| s.as_str()).collect();
|
||||
let opened = client.opened(&specs_ref).unwrap_or_default();
|
||||
let mut by_path: std::collections::HashMap<PathBuf, P4FileStatus> = std::collections::HashMap::new();
|
||||
for of in opened {
|
||||
let rel = self.rel(&of.path.0);
|
||||
by_path.insert(
|
||||
of.path.0.clone(),
|
||||
P4FileStatus {
|
||||
path: of.path.clone(),
|
||||
rel_path: rel,
|
||||
opened: Some(of.action),
|
||||
modified: of.action != ChangeAction::Other,
|
||||
untracked: false,
|
||||
change: of.change,
|
||||
},
|
||||
);
|
||||
}
|
||||
// fstat marks files differing from have (`status`/`...diff`) so a synced
|
||||
// but locally-edited file shows Modified even before `p4 edit`.
|
||||
for of in client.fstat(&specs_ref).unwrap_or_default() {
|
||||
let entry = by_path.entry(of.path.0.clone()).or_insert_with(|| P4FileStatus {
|
||||
path: of.path.clone(),
|
||||
rel_path: self.rel(&of.path.0),
|
||||
opened: None,
|
||||
modified: false,
|
||||
untracked: false,
|
||||
change: 0,
|
||||
});
|
||||
if of.action != ChangeAction::Other {
|
||||
entry.opened = Some(of.action);
|
||||
entry.modified = true;
|
||||
}
|
||||
}
|
||||
Ok(by_path.into_values().collect())
|
||||
}
|
||||
|
||||
/// Local path or depot spec → a `p4` path spec. Absolute local paths and
|
||||
/// `//depot/…` pass through; bare names become `root/name`.
|
||||
fn to_spec(&self, p: &str) -> String {
|
||||
if p.starts_with("//") || Path::new(p).is_absolute() {
|
||||
return p.to_string();
|
||||
}
|
||||
self.root.join(p).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn rel(&self, path: &Path) -> String {
|
||||
path.strip_prefix(&self.root)
|
||||
.map(|r| r.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| path.to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a file read-write so `p4 edit` can open it (Perforce keeps unopened
|
||||
/// files read-only). Called by the checkout prompt for read-only depot files.
|
||||
#[allow(dead_code)]
|
||||
pub fn make_writable(path: &Path) -> IdeResult<()> {
|
||||
let mut perms = std::fs::metadata(path)?.permissions();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
perms.set_mode(perms.mode() | 0o200);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
#[allow(clippy::permissions_set_readonly_false)]
|
||||
perms.set_readonly(false);
|
||||
}
|
||||
std::fs::set_permissions(path, perms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `path` is read-only on disk — the signal for "checkout on edit".
|
||||
#[allow(dead_code)]
|
||||
pub fn is_readonly(path: &Path) -> bool {
|
||||
std::fs::metadata(path).map(|m| m.permissions().readonly()).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn to_spec_maps_relative_paths() {
|
||||
let w = Workspace {
|
||||
root: PathBuf::from("/ws"),
|
||||
conn: crate::client::Connection {
|
||||
server_address: "perforce:1666".into(),
|
||||
user_name: "u".into(),
|
||||
client_name: "c".into(),
|
||||
client_root: "/ws".into(),
|
||||
client_host: None,
|
||||
server_version: None,
|
||||
},
|
||||
exe: None,
|
||||
};
|
||||
assert_eq!(w.to_spec("//depot/x.rs"), "//depot/x.rs");
|
||||
assert_eq!(w.to_spec("/abs/x.rs"), "/abs/x.rs");
|
||||
assert_eq!(w.to_spec("src/x.rs"), Path::new("/ws").join("src/x.rs").to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f = dir.path().join("a.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
assert!(!is_readonly(&f));
|
||||
let mut p = std::fs::metadata(&f).unwrap().permissions();
|
||||
p.set_readonly(true);
|
||||
std::fs::set_permissions(&f, p).unwrap();
|
||||
assert!(is_readonly(&f));
|
||||
make_writable(&f).unwrap();
|
||||
assert!(!is_readonly(&f));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user