Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/macos-wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ jobs:
name: subvertpy wheel build for Python ${{ matrix.python-version }}
on ${{ matrix.config.os }}
runs-on: ${{ matrix.config.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
Expand Down
26 changes: 26 additions & 0 deletions client/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,32 @@ impl Client {
Ok(())
}

/// Restore pristine working copy file (undo all local edits)
#[pyo3(signature = (paths, depth=None, recursive=true))]
fn revert(
&mut self,
py: Python,
paths: Vec<String>,
depth: Option<Bound<PyAny>>,
recursive: bool,
) -> PyResult<()> {
let svn_depth = if let Some(d) = depth {
parse_depth(py, &d)?
} else if recursive {
subversion::Depth::Infinity
} else {
subversion::Depth::Empty
};
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
let options = subversion::client::RevertOptions {
depth: svn_depth,
..Default::default()
};
self.ctx
.revert(&path_refs, &options)
.map_err(|e| subvertpy_util::error::svn_err_to_py(e))
}

/// Commit changes to the repository
#[pyo3(signature = (targets, recurse=true, keep_locks=true, keep_changelist=false, commit_as_operations=false, include_file_externals=false, include_dir_externals=false, revprops=None, callback=None))]
fn commit(
Expand Down
76 changes: 71 additions & 5 deletions ra/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,14 @@ impl RemoteAccess {
include_merged_revisions: Option<bool>,
) -> PyResult<()> {
let path = subvertpy_util::to_relpath(path)?;
let start_rev = subvertpy_util::to_revnum_or_head(start);
// A negative start means "from the beginning of the file's history";
// svn_ra_get_file_revs2 expects revision 0 for that, not an invalid
// revnum (which it would treat as the youngest revision).
let start_rev = if start < 0 {
subversion::Revnum::from(0u64)
} else {
subvertpy_util::to_revnum_or_head(start)
};
let end_rev = subvertpy_util::to_revnum_or_head(end);

let py_handler = handler.clone();
Expand Down Expand Up @@ -899,11 +906,58 @@ impl RemoteAccess {
subversion::Error::from_message(&format!("Failed to convert args: {}", e))
})?;

py_handler.call1(&args).map_err(|e| {
let result = py_handler.call1(&args).map_err(|e| {
subversion::Error::from_message(&format!("Python callback error: {}", e))
})?;

Ok(None)
// The Python handler may return a txdelta window callback; if
// so, wire it up so the deltas for this revision are delivered.
if result.is_none() {
return Ok(None);
}
let window_handler = result.unbind();
let handler: subversion::ra::TxDeltaHandler = Box::new(
move |window: Option<&subversion::delta::TxDeltaWindowRef<'_>>| {
Python::attach(|py| {
let py_window: Py<pyo3::PyAny> = match window {
None => py.None(),
Some(w) => {
let ops = w.ops();
let py_ops = pyo3::types::PyList::new(
py,
ops.iter().map(|&(a, o, l)| (a, o, l)),
)
.map_err(|e| {
subversion::Error::from_message(&format!("{}", e))
})?;
let new_data = PyBytes::new(py, w.new_data());
(
w.sview_offset(),
w.sview_len(),
w.tview_len(),
w.src_ops(),
py_ops,
new_data,
)
.into_pyobject(py)
.map_err(|e| {
subversion::Error::from_message(&format!("{}", e))
})?
.into_any()
.unbind()
}
};
window_handler.call1(py, (py_window,)).map_err(|e| {
subversion::Error::from_message(&format!(
"txdelta window handler error: {}",
e
))
})?;
Ok(())
})
},
);
Ok(Some(handler))
})
};

Expand Down Expand Up @@ -1337,8 +1391,20 @@ impl RemoteAccess {
Python::attach(|py| {
let dict = PyDict::new(py);
for (path, mergeinfo) in &result {
let mi_str = mergeinfo.to_string().map_err(|e| svn_err_to_py(e))?;
dict.set_item(path, mi_str)?;
let inner = PyDict::new(py);
for (mi_path, ranges) in mergeinfo.paths_with_ranges() {
let list = pyo3::types::PyList::empty(py);
for r in ranges {
let tuple = (
r.start.as_i64(),
r.end.as_i64(),
if r.inheritable { 1 } else { 0 },
);
list.append(tuple)?;
}
inner.set_item(mi_path, list)?;
}
dict.set_item(path, inner)?;
}
Ok(Some(dict.into_any().unbind()))
})
Expand Down
1 change: 1 addition & 0 deletions subvertpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
ERR_WC_NOT_WORKING_COPY = ERR_WC_NOT_DIRECTORY = 155007
ERR_ENTRY_EXISTS = 150002
ERR_WC_PATH_NOT_FOUND = 155010
ERR_WC_CORRUPT = 155016
ERR_WC_PATH_UNEXPECTED_STATUS = 155035
ERR_CANCELLED = 200015
ERR_WC_UNSUPPORTED_FORMAT = 155021
Expand Down
67 changes: 53 additions & 14 deletions subvertpy_util/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,12 @@ impl PyEditor {
.close()
.map_err(|e| crate::error::svn_err_to_py(e));

if result.is_ok() {
self.closed = true;
// Call on_close callback if set
if let Some(callback) = self.on_close.take() {
callback();
}
// The edit is over either way: mark closed and release resources (e.g.
// the session busy flag) even if close itself failed, otherwise the
// session stays permanently busy.
self.closed = true;
if let Some(callback) = self.on_close.take() {
callback();
}

result
Expand All @@ -135,12 +135,9 @@ impl PyEditor {
.abort()
.map_err(|e| crate::error::svn_err_to_py(e));

if result.is_ok() {
self.closed = true;
// Call on_close callback if set
if let Some(callback) = self.on_close.take() {
callback();
}
self.closed = true;
if let Some(callback) = self.on_close.take() {
callback();
}

result
Expand All @@ -153,11 +150,21 @@ impl PyEditor {
fn __exit__(
&mut self,
py: Python,
_exc_type: Bound<pyo3::PyAny>,
exc_type: Bound<pyo3::PyAny>,
_exc_value: Bound<pyo3::PyAny>,
_traceback: Bound<pyo3::PyAny>,
) -> PyResult<bool> {
self.close(py)?;
if self.closed {
return Ok(false);
}
// On a normal exit close the edit; if an exception is propagating,
// abort instead so the editor (and the session it holds busy) is
// released even though the commit did not complete.
if exc_type.is_none() {
self.close(py)?;
} else {
self.abort(py)?;
}
Ok(false)
}
}
Expand Down Expand Up @@ -404,8 +411,40 @@ pub struct PyFileEditor {
parent_active_child: Option<Rc<Cell<bool>>>,
}

/// PyCapsule name identifying a borrowed `*const WrapFileEditor<'static>`.
///
/// The `wc` extension module looks up a capsule with this exact name to
/// recover the underlying file editor for the deprecated adm-based
/// transmit_*_deltas functions. Sharing the editor across extension modules
/// via a capsule sidesteps the fact that each cdylib gets its own distinct
/// `FileEditor` Python type object (so PyO3 downcasting across modules fails).
pub const WRAP_FILE_EDITOR_CAPSULE_NAME: &std::ffi::CStr = c"subvertpy._wrap_file_editor";

#[pymethods]
impl PyFileEditor {
/// Return a PyCapsule wrapping a borrowed pointer to the underlying
/// `WrapFileEditor`.
///
/// This lets other extension modules (notably ``wc``) recover the real
/// delta editor and baton from the commit drive, which is required by the
/// deprecated adm-based ``transmit_*_deltas`` functions. The capsule
/// borrows from this object, so the caller must keep this ``FileEditor``
/// alive while using the capsule.
fn _wrap_file_editor_capsule<'py>(
&self,
py: Python<'py>,
) -> PyResult<Bound<'py, pyo3::types::PyCapsule>> {
let ptr = &self.editor as *const WrapFileEditor<'static> as *mut std::ffi::c_void;
// SAFETY: `ptr` borrows `self.editor`, which stays valid for as long as
// this PyFileEditor is alive. There is no destructor; ownership remains
// with this object.
let non_null =
std::ptr::NonNull::new(ptr).expect("address of a struct field is never null");
unsafe {
pyo3::types::PyCapsule::new_with_pointer(py, non_null, WRAP_FILE_EDITOR_CAPSULE_NAME)
}
}

#[pyo3(signature = (base_checksum=None))]
fn apply_textdelta(
&mut self,
Expand Down
Loading
Loading