mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Update dependencies and implement git-based sync direction defaults
- Updated `clap` and `unicode-ident` versions in `Cargo.lock` and `Cargo.toml`. - Added `sha1` crate to `Cargo.toml` for SHA1 hashing functionality. - Introduced a new `git.rs` module for handling git operations, including changed files and SHA1 computation. - Enhanced the sync confirmation dialog to auto-select defaults based on git status, improving user experience during two-way sync. - Updated `PatchTree` to utilize git metadata for determining default selections during patch application. These changes enhance the integration with git, providing smarter defaults and improving the overall sync process.
This commit is contained in:
parent
df22500102
commit
7ba04f7f6a
29 changed files with 1285 additions and 176 deletions
|
|
@ -2,6 +2,7 @@ use crossbeam_channel::{select, Receiver, RecvError, Sender};
|
|||
use jod_thread::JoinHandle;
|
||||
use memofs::{IoResultExt, Vfs, VfsEvent};
|
||||
use rbx_dom_weak::types::{Ref, Variant};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -83,6 +84,7 @@ impl ChangeProcessor {
|
|||
ref_path_index: Arc<Mutex<crate::RefPathIndex>>,
|
||||
project_root: PathBuf,
|
||||
critical_error_receiver: Option<Receiver<memofs::WatcherCriticalError>>,
|
||||
git_repo_root: Option<PathBuf>,
|
||||
) -> Self {
|
||||
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
|
||||
let vfs_receiver = vfs.event_receiver();
|
||||
|
|
@ -101,6 +103,7 @@ impl ChangeProcessor {
|
|||
suppressed_paths,
|
||||
project_root,
|
||||
ref_path_index,
|
||||
git_repo_root,
|
||||
};
|
||||
|
||||
let job_thread = jod_thread::Builder::new()
|
||||
|
|
@ -245,6 +248,10 @@ struct JobThreadContext {
|
|||
/// Index of meta/model files that contain `Rojo_Ref_*` attributes.
|
||||
/// Shared with ApiService for efficient rename path updates.
|
||||
ref_path_index: Arc<Mutex<crate::RefPathIndex>>,
|
||||
|
||||
/// Git repository root, if the project is in a git repo.
|
||||
/// Used for auto-staging Source writes.
|
||||
git_repo_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl JobThreadContext {
|
||||
|
|
@ -1005,6 +1012,7 @@ impl JobThreadContext {
|
|||
added_instances: patch_set.added_instances,
|
||||
removed_instances: patch_set.removed_instances,
|
||||
updated_instances: Vec::new(),
|
||||
stage_ids: HashSet::new(),
|
||||
};
|
||||
|
||||
let applied = apply_patch_set(&mut tree, structural_patch);
|
||||
|
|
@ -1812,6 +1820,10 @@ impl JobThreadContext {
|
|||
id,
|
||||
err
|
||||
);
|
||||
} else if patch_set.stage_ids.contains(&id) {
|
||||
if let Some(ref repo_root) = self.git_repo_root {
|
||||
crate::git::git_add(repo_root, std::slice::from_ref(write_path));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("Cannot change Source to non-string value.");
|
||||
|
|
|
|||
229
src/git.rs
Normal file
229
src/git.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use rbx_dom_weak::types::Ref;
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use crate::{snapshot::RojoTree, web::interface::GitMetadata};
|
||||
|
||||
const SCRIPT_CLASSES: &[&str] = &["Script", "LocalScript", "ModuleScript"];
|
||||
|
||||
fn is_script_class(class: &str) -> bool {
|
||||
SCRIPT_CLASSES.contains(&class)
|
||||
}
|
||||
|
||||
pub fn git_repo_root(project_root: &Path) -> Option<PathBuf> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
&project_root.to_string_lossy(),
|
||||
"rev-parse",
|
||||
"--show-toplevel",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let path_str = stdout.trim();
|
||||
if path_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(PathBuf::from(path_str))
|
||||
}
|
||||
|
||||
fn git_changed_files(repo_root: &Path) -> Option<HashSet<PathBuf>> {
|
||||
let mut changed = HashSet::new();
|
||||
|
||||
let diff_output = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
&repo_root.to_string_lossy(),
|
||||
"diff",
|
||||
"HEAD",
|
||||
"--name-only",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if diff_output.status.success() {
|
||||
for line in String::from_utf8_lossy(&diff_output.stdout).lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
changed.insert(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!("git diff HEAD failed (possibly no commits yet), skipping diff");
|
||||
}
|
||||
|
||||
let untracked_output = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
&repo_root.to_string_lossy(),
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if untracked_output.status.success() {
|
||||
for line in String::from_utf8_lossy(&untracked_output.stdout).lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
changed.insert(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed.is_empty() && !diff_output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(changed)
|
||||
}
|
||||
|
||||
fn git_show(repo_root: &Path, object_ref: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["-C", &repo_root.to_string_lossy(), "show", object_ref])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8(output.stdout).ok()
|
||||
}
|
||||
|
||||
fn git_show_head(repo_root: &Path, rel_path: &Path) -> Option<String> {
|
||||
let object_ref = format!("HEAD:{}", rel_path.to_string_lossy().replace('\\', "/"));
|
||||
git_show(repo_root, &object_ref)
|
||||
}
|
||||
|
||||
fn git_show_staged(repo_root: &Path, rel_path: &Path) -> Option<String> {
|
||||
let object_ref = format!(":0:{}", rel_path.to_string_lossy().replace('\\', "/"));
|
||||
git_show(repo_root, &object_ref)
|
||||
}
|
||||
|
||||
pub fn compute_blob_sha1(content: &str) -> String {
|
||||
let header = format!("blob {}\0", content.len());
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(header.as_bytes());
|
||||
hasher.update(content.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn git_add(repo_root: &Path, paths: &[PathBuf]) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(["-C", &repo_root.to_string_lossy(), "add", "--"]);
|
||||
for path in paths {
|
||||
cmd.arg(path);
|
||||
}
|
||||
|
||||
match cmd.output() {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("git add failed: {}", stderr.trim());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to run git add: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedInstance {
|
||||
id: Ref,
|
||||
class_name: String,
|
||||
rel_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Computes git metadata for the two-way sync confirmation UI.
|
||||
///
|
||||
/// Uses a two-phase approach to avoid holding the tree lock during I/O:
|
||||
/// 1. Run git commands to get changed files (no lock)
|
||||
/// 2. Briefly lock tree to resolve file paths to instance Refs and class names
|
||||
/// 3. Run git show for each changed script and compute hashes (no lock)
|
||||
pub fn compute_git_metadata(
|
||||
tree_handle: &Arc<Mutex<RojoTree>>,
|
||||
project_root: &Path,
|
||||
) -> Option<GitMetadata> {
|
||||
let repo_root = git_repo_root(project_root)?;
|
||||
let changed_files = git_changed_files(&repo_root)?;
|
||||
|
||||
if changed_files.is_empty() {
|
||||
return Some(GitMetadata {
|
||||
changed_ids: Vec::new(),
|
||||
script_committed_hashes: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Brief lock: resolve changed file paths to instance Refs and class names
|
||||
let resolved: Vec<ResolvedInstance> = {
|
||||
let tree = tree_handle.lock().unwrap();
|
||||
let mut result = Vec::new();
|
||||
|
||||
for rel_path in &changed_files {
|
||||
let abs_path = repo_root.join(rel_path);
|
||||
let ids = tree.get_ids_at_path(&abs_path);
|
||||
|
||||
for &id in ids {
|
||||
if let Some(instance) = tree.get_instance(id) {
|
||||
result.push(ResolvedInstance {
|
||||
id,
|
||||
class_name: instance.class_name().to_string(),
|
||||
rel_path: rel_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}; // Lock released
|
||||
|
||||
let changed_ids: Vec<Ref> = resolved.iter().map(|ri| ri.id).collect();
|
||||
let mut script_committed_hashes: HashMap<Ref, Vec<String>> = HashMap::new();
|
||||
|
||||
for ri in &resolved {
|
||||
if !is_script_class(&ri.class_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut hashes = Vec::with_capacity(2);
|
||||
|
||||
if let Some(head_content) = git_show_head(&repo_root, &ri.rel_path) {
|
||||
hashes.push(compute_blob_sha1(&head_content));
|
||||
}
|
||||
|
||||
if let Some(staged_content) = git_show_staged(&repo_root, &ri.rel_path) {
|
||||
let staged_hash = compute_blob_sha1(&staged_content);
|
||||
if hashes.is_empty() || hashes[0] != staged_hash {
|
||||
hashes.push(staged_hash);
|
||||
}
|
||||
}
|
||||
|
||||
if !hashes.is_empty() {
|
||||
script_committed_hashes.insert(ri.id, hashes);
|
||||
}
|
||||
}
|
||||
|
||||
Some(GitMetadata {
|
||||
changed_ids,
|
||||
script_committed_hashes,
|
||||
})
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ pub mod cli;
|
|||
mod tree_view;
|
||||
|
||||
mod change_processor;
|
||||
pub mod git;
|
||||
mod glob;
|
||||
mod json;
|
||||
mod lua_ast;
|
||||
|
|
|
|||
|
|
@ -119,6 +119,10 @@ pub struct ServeSession {
|
|||
/// Shared between ApiService (writes) and ChangeProcessor (rename updates).
|
||||
/// `None` for oneshot sessions.
|
||||
ref_path_index: Option<Arc<Mutex<crate::RefPathIndex>>>,
|
||||
|
||||
/// Root of the git repository, if the project is inside one.
|
||||
/// Computed once at session start for use by auto-staging.
|
||||
git_repo_root: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl ServeSession {
|
||||
|
|
@ -172,6 +176,8 @@ impl ServeSession {
|
|||
let suppressed_paths = Arc::new(Mutex::new(std::collections::HashMap::new()));
|
||||
let ref_path_index = Arc::new(Mutex::new(crate::RefPathIndex::new()));
|
||||
|
||||
let git_repo_root = crate::git::git_repo_root(root_project.folder_location());
|
||||
|
||||
log::trace!("Starting ChangeProcessor");
|
||||
let change_processor = ChangeProcessor::start(
|
||||
Arc::clone(&tree),
|
||||
|
|
@ -182,6 +188,7 @@ impl ServeSession {
|
|||
Arc::clone(&ref_path_index),
|
||||
root_project.folder_location().to_path_buf(),
|
||||
critical_error_receiver,
|
||||
git_repo_root.clone(),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
|
|
@ -195,6 +202,7 @@ impl ServeSession {
|
|||
vfs,
|
||||
suppressed_paths: Some(suppressed_paths),
|
||||
ref_path_index: Some(ref_path_index),
|
||||
git_repo_root,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +228,7 @@ impl ServeSession {
|
|||
vfs: Arc::new(vfs),
|
||||
suppressed_paths: None,
|
||||
ref_path_index: None,
|
||||
git_repo_root: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -312,6 +321,10 @@ impl ServeSession {
|
|||
self.root_project.folder_location()
|
||||
}
|
||||
|
||||
pub fn git_repo_root(&self) -> Option<&Path> {
|
||||
self.git_repo_root.as_deref()
|
||||
}
|
||||
|
||||
pub fn root_project(&self) -> &Project {
|
||||
&self.root_project
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! Defines the data structures used for describing instance patches.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rbx_dom_weak::{
|
||||
types::{Ref, Variant},
|
||||
HashMapExt as _, Ustr, UstrMap,
|
||||
|
|
@ -18,6 +20,10 @@ pub struct PatchSet {
|
|||
pub removed_instances: Vec<Ref>,
|
||||
pub added_instances: Vec<PatchAdd>,
|
||||
pub updated_instances: Vec<PatchUpdate>,
|
||||
/// Instance IDs whose backing files should be staged via git add after writing.
|
||||
/// Populated from WriteRequest.stage_ids, consumed by change_processor.
|
||||
#[serde(default, skip)]
|
||||
pub stage_ids: HashSet<Ref>,
|
||||
}
|
||||
|
||||
impl PatchSet {
|
||||
|
|
@ -26,6 +32,7 @@ impl PatchSet {
|
|||
removed_instances: Vec::new(),
|
||||
added_instances: Vec::new(),
|
||||
updated_instances: Vec::new(),
|
||||
stage_ids: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -391,6 +391,7 @@ mod test {
|
|||
parent_id: root_id,
|
||||
instance: snapshot.clone(),
|
||||
}],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -438,6 +439,7 @@ mod test {
|
|||
|
||||
let patch_set = PatchSet {
|
||||
updated_instances: vec![patch],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ mod test {
|
|||
}],
|
||||
added_instances: Vec::new(),
|
||||
removed_instances: Vec::new(),
|
||||
stage_ids: std::collections::HashSet::new(),
|
||||
};
|
||||
|
||||
assert_eq!(patch_set, expected_patch_set);
|
||||
|
|
@ -397,6 +398,7 @@ mod test {
|
|||
}],
|
||||
updated_instances: Vec::new(),
|
||||
removed_instances: Vec::new(),
|
||||
stage_ids: std::collections::HashSet::new(),
|
||||
};
|
||||
|
||||
assert_eq!(patch_set, expected_patch_set);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use insta::assert_yaml_snapshot;
|
||||
|
||||
use rbx_dom_weak::{ustr, UstrMap};
|
||||
|
|
@ -23,6 +25,7 @@ fn set_name_and_class_name() {
|
|||
changed_properties: Default::default(),
|
||||
changed_metadata: None,
|
||||
}],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -50,6 +53,7 @@ fn add_property() {
|
|||
changed_properties: UstrMap::from_iter([(ustr("Foo"), Some("Value of Foo".into()))]),
|
||||
changed_metadata: None,
|
||||
}],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -89,6 +93,7 @@ fn remove_property() {
|
|||
changed_properties: UstrMap::from_iter([(ustr("Foo"), None)]),
|
||||
changed_metadata: None,
|
||||
}],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -144,6 +149,7 @@ fn apply_ref_property_update() {
|
|||
)]),
|
||||
changed_metadata: None,
|
||||
}],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -203,6 +209,7 @@ fn apply_nil_ref_skips_in_forward_sync() {
|
|||
)]),
|
||||
changed_metadata: None,
|
||||
}],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -258,6 +265,7 @@ fn apply_ref_property_removal() {
|
|||
changed_properties: UstrMap::from_iter([(ustr("PrimaryPart"), None)]),
|
||||
changed_metadata: None,
|
||||
}],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -314,6 +322,7 @@ fn apply_multiple_ref_property_updates() {
|
|||
changed_metadata: None,
|
||||
},
|
||||
],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -365,6 +374,7 @@ fn apply_ref_to_nonexistent_instance() {
|
|||
)]),
|
||||
changed_metadata: None,
|
||||
}],
|
||||
stage_ids: HashSet::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -264,8 +264,7 @@ impl ApiService {
|
|||
|
||||
/// Get a summary of information about the server
|
||||
async fn handle_api_rojo(&self) -> Response<Full<Bytes>> {
|
||||
let tree = self.serve_session.tree();
|
||||
let root_instance_id = tree.get_root_id();
|
||||
let root_instance_id = self.serve_session.tree().get_root_id();
|
||||
|
||||
let ignore_hidden_services = self.serve_session.ignore_hidden_services();
|
||||
let visible_services = if ignore_hidden_services {
|
||||
|
|
@ -274,6 +273,11 @@ impl ApiService {
|
|||
Vec::new()
|
||||
};
|
||||
|
||||
let git_metadata = crate::git::compute_git_metadata(
|
||||
&self.serve_session.tree_handle(),
|
||||
self.serve_session.root_dir(),
|
||||
);
|
||||
|
||||
msgpack_ok(&ServerInfoResponse {
|
||||
server_version: SERVER_VERSION.to_owned(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
|
|
@ -288,6 +292,7 @@ impl ApiService {
|
|||
sync_source_only: true,
|
||||
ignore_hidden_services,
|
||||
visible_services,
|
||||
git_metadata,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -735,14 +740,38 @@ impl ApiService {
|
|||
})
|
||||
.collect();
|
||||
|
||||
let stage_ids: HashSet<Ref> = request.stage_ids.into_iter().collect();
|
||||
|
||||
tree_mutation_sender
|
||||
.send(PatchSet {
|
||||
removed_instances: actually_removed,
|
||||
added_instances,
|
||||
updated_instances,
|
||||
stage_ids: stage_ids.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
if let Some(repo_root) = self.serve_session.git_repo_root() {
|
||||
if !stage_ids.is_empty() {
|
||||
let tree = self.serve_session.tree();
|
||||
let paths_to_stage: Vec<PathBuf> = stage_ids
|
||||
.iter()
|
||||
.filter_map(|&id| {
|
||||
tree.get_instance(id).and_then(|inst| {
|
||||
inst.metadata().instigating_source.as_ref().map(|src| {
|
||||
src.path().to_path_buf()
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
drop(tree);
|
||||
|
||||
if !paths_to_stage.is_empty() {
|
||||
crate::git::git_add(repo_root, &paths_to_stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msgpack_ok(WriteResponse { session_id })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,16 @@ fn property_filter(value: Option<&Variant>) -> bool {
|
|||
ty != Some(VariantType::SharedString)
|
||||
}
|
||||
|
||||
/// Git-related metadata sent to the plugin for smart sync direction defaults.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitMetadata {
|
||||
pub changed_ids: Vec<Ref>,
|
||||
/// SHA1 hashes of prior versions (git blob format) for changed script instances.
|
||||
/// Contains 1-2 hashes per script: HEAD version and staged version (if different).
|
||||
pub script_committed_hashes: HashMap<Ref, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Response body from /api/rojo
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -202,6 +212,8 @@ pub struct ServerInfoResponse {
|
|||
/// Services not in this list should be ignored during sync operations.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub visible_services: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub git_metadata: Option<GitMetadata>,
|
||||
}
|
||||
|
||||
// Serialize place IDs as f64 to avoid msgpack uint64 encoding issues with Lua
|
||||
|
|
@ -287,6 +299,9 @@ pub struct WriteRequest {
|
|||
#[serde(default)]
|
||||
pub added: HashMap<Ref, AddedInstance>,
|
||||
pub updated: Vec<InstanceUpdate>,
|
||||
/// Instance IDs whose backing files should be staged via git add.
|
||||
#[serde(default)]
|
||||
pub stage_ids: Vec<Ref>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue