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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ the resolved app appearance automatically.

## Features

- **Personal user actions** — define repository, ref, and working-tree file
commands in Settings → Integrations. Menu and palette entries open an exact
executable/argument/working-directory preview, with bounded output and cancellation.

- **Repository size controls** — clone a chosen branch with optional depth,
single-branch fetching, on-demand file contents (`blob:none`), and recursive
submodules. Inspect clone scope and download more or full history from the
Expand Down
13 changes: 12 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2170,7 +2170,8 @@ and Store certification remain external gates.
- ☑ Expanded submodule lifecycle — guarded add/remove/deinit/sync/URL, paged
nested inspection and cancellable updates (`SubmoduleDialog`); real Git
preservation fixtures and native lifecycle/keyboard checks pass.
- Repository/ref/file custom actions with safe argv templates
- ☑ Repository/ref/file custom actions with safe argv templates
(`UserActionsEditor`, context menus / Quick Launch, `UserActionDialog`)
- **CLI companion binary (`strand`)** — `strand <path>` opens the repo
in the app; `strand diff/log/status/review --json` gives AI agents
typed, full-context data the `git` porcelain can't (same serde types
Expand Down Expand Up @@ -2813,6 +2814,16 @@ implementation rows while the July audit is labeled historical. This is a
planning update, not a claim that these features shipped; existing local Git,
GitHub/Azure review, Workbench and performance work retain their own status.

**Personal user actions shipped (2026-09-06, F15):** Settings → Integrations
now edits explicit executable/argv definitions for repositories, qualified refs,
and working-tree files. Context menus and Quick Launch capture the target and
require a resolved executable/arguments/cwd preview. Native execution revalidates
paths and ref IDs, preserves argv boundaries, bounds both output streams, and
cancels the process tree. Definitions stay in personal settings, separate from
Workbench and plugins. Automated tests and an isolated Windows WebView2 pass
covered literal spaces/metacharacters, stale selections, exact menu targets,
nonzero exits, output limits, keyboard operation, and cancellation.

**Sparse checkout and clone controls shipped (2026-09-06, F08/F09):** Clone now
offers branch, independent depth/single-branch choices, blob filtering and
recursive submodules. Repository history controls inspect external clones and
Expand Down
5 changes: 3 additions & 2 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,10 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git-
- ☐ **F14 / P2 — Publish a new hosted repository.** Provider/account/visibility
selection, concrete destination review, remote configuration and explicit
initial push, with recovery from partial failure.
- **F15 / P2 — User-defined repository/ref/file actions.** Safe executable/
- **F15 / P2 — User-defined repository/ref/file actions.** Safe executable/
argv templates, exact context, palette/menu discovery, preview, bounded output
and cancellation; editor/terminal templates and internal registries already exist.
and cancellation. (`UserActionsEditor`, `UserActionDialog`,
`repo_user_action_preview` / `repo_user_action_run`; personally persisted settings.)
- ☐ **F18 / P3 — Advanced refs.** Git notes/replace-ref management and explicit
tag retarget/re-annotation with current/new target review. Signed tags are F03;
existing local Review notes are separate from Git notes.
Expand Down
3 changes: 3 additions & 0 deletions crates/strand-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true

[dev-dependencies]
tempfile = "3"

[target.'cfg(unix)'.dependencies]
libc = "0.2"
1 change: 1 addition & 0 deletions crates/strand-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod maintenance;
pub mod lfs;
pub mod conflict;
pub mod external;
pub mod user_actions;
pub mod gitconfig;
mod git_output;
pub mod history;
Expand Down
253 changes: 253 additions & 0 deletions crates/strand-core/src/user_actions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//! Personally configured actions. Argument boundaries exist before substitution;
//! repository values are never parsed as command syntax or substituted twice.
use serde::{Deserialize, Serialize};

use crate::{Error, Repo, Result};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct UserAction {
pub id: String,
pub name: String,
pub scope: String,
pub executable: String,
pub args: Vec<String>,
pub cwd: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ActionTarget {
Repository,
Ref { reference: String, oid: String },
File { file: String },
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ActionContext {
pub path: String,
pub target: ActionTarget,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ActionPreview {
pub executable: String,
pub args: Vec<String>,
pub cwd: String,
}

fn invalid(message: &str) -> Error {
Error::Other(message.into())
}

/// Expand only template text. Escaped braces allow literal script/JSON arguments.
fn substitute(template: &str, vars: &[(&str, String)]) -> Result<String> {
let mut out = String::new();
let mut rest = template;
while !rest.is_empty() {
if rest.starts_with("{{") {
out.push('{');
rest = &rest[2..];
} else if rest.starts_with("}}") {
out.push('}');
rest = &rest[2..];
} else if rest.starts_with('{') {
let end = rest
.find('}')
.ok_or_else(|| invalid("Unclosed action placeholder"))?;
let key = &rest[1..end];
let value = vars
.iter()
.find(|(name, _)| *name == key)
.ok_or_else(|| invalid(&format!("Unavailable action placeholder: {{{key}}}")))?;
out.push_str(&value.1);
rest = &rest[end + 1..];
} else {
let ch = rest.chars().next().unwrap();
out.push(ch);
rest = &rest[ch.len_utf8()..];
}
}
Ok(out)
}

impl Repo {
pub fn preview_user_action(
&self,
action: &UserAction,
context: &ActionContext,
) -> Result<ActionPreview> {
if action.name.trim().is_empty()
|| action.name.len() > 120
|| action.args.len() > 128
|| action.executable.trim().is_empty()
|| action.executable.contains('\0')
|| action.executable.len() > 4096
|| action.args.iter().map(String::len).sum::<usize>() > 24_000
|| action.args.iter().any(|arg| arg.contains('\0'))
{
return Err(invalid("Invalid action: use a name, a literal executable, and at most 128 arguments / 24 KB"));
}
let root = self.path().canonicalize()?;
let mut cwd = root.clone();
let mut vars = vec![("repo", root.to_string_lossy().into_owned())];
match &context.target {
ActionTarget::Repository if action.scope == "repository" => {}
ActionTarget::Ref { reference, oid } if action.scope == "ref" => {
if !reference.starts_with("refs/") {
return Err(invalid("Select a qualified branch or tag ref"));
}
let current = self
.git2()?
.find_reference(reference)?
.peel_to_commit()?
.id()
.to_string();
if &current != oid {
return Err(invalid(
"Selected ref changed. Close this preview and select it again.",
));
}
vars.extend([("ref", reference.clone()), ("oid", current)]);
}
ActionTarget::File { file } if action.scope == "file" => {
let full = self.workdir_path(file)?.canonicalize()?;
if !full.is_file() {
return Err(invalid("Select an existing working-tree file"));
}
// A replaced symlink must never redirect a preview outside this checkout.
if !full.starts_with(&root) {
return Err(invalid("File escapes the working tree"));
}
if action.cwd == "file-parent" {
cwd = full.parent().unwrap().to_owned();
}
vars.extend([
("file", full.to_string_lossy().into_owned()),
("relativeFile", file.clone()),
]);
}
_ => return Err(invalid("Action scope does not match the selected context")),
}
if action.cwd != "repository" && !(action.cwd == "file-parent" && action.scope == "file") {
return Err(invalid(
"Working directory must be the repository or selected file's parent",
));
}
let mut args = Vec::new();
if std::path::Path::new(&action.executable)
.file_stem()
.is_some_and(|name| name.eq_ignore_ascii_case("git"))
{
args.extend(crate::GIT_SAFE_CONFIG.iter().map(|arg| arg.to_string()));
}
for arg in &action.args {
args.push(substitute(arg, &vars)?);
}
if args.iter().map(String::len).sum::<usize>() > 28_000 {
return Err(invalid("Resolved arguments exceed 28 KB"));
}
Ok(ActionPreview {
executable: action.executable.clone(),
args,
cwd: cwd.to_string_lossy().into_owned(),
})
}
}

#[cfg(test)]
mod tests {
use super::*;

fn action(scope: &str, args: &[&str]) -> UserAction {
UserAction {
id: "test".into(),
name: "Test".into(),
scope: scope.into(),
executable: "probe".into(),
args: args.iter().map(|s| s.to_string()).collect(),
cwd: "repository".into(),
}
}

#[test]
fn substitution_preserves_boundaries_and_does_not_reexpand_values() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path().join("repo space & %PATH% {oid}");
std::fs::create_dir(&dir).unwrap();
git2::Repository::init(&dir).unwrap();
let file = "file & %PATH% {repo}.txt";
std::fs::write(dir.join(file), "text").unwrap();
let repo = Repo::discover(&dir).unwrap();
let context = ActionContext {
path: dir.to_string_lossy().into_owned(),
target: ActionTarget::File { file: file.into() },
};
let mut definition = action(
"file",
&["--", "{relativeFile}", "prefix={file}", "", "{{literal}}"],
);
definition.cwd = "file-parent".into();
definition.executable = dir.join("tool {repo}").to_string_lossy().into_owned();
let preview = repo.preview_user_action(&definition, &context).unwrap();
assert_eq!(preview.executable, definition.executable);
assert_eq!(preview.args[1], file);
assert!(preview.args[2].ends_with(file));
assert_eq!(&preview.args[3..], &["", "{literal}"]);
assert_eq!(preview.cwd, dir.canonicalize().unwrap().to_string_lossy());
definition.args = vec!["{ref}".into()];
assert!(repo.preview_user_action(&definition, &context).is_err());
let outside = ActionContext {
target: ActionTarget::File {
file: "../outside".into(),
},
..context.clone()
};
assert!(repo
.preview_user_action(&action("file", &[]), &outside)
.is_err());
std::fs::remove_file(dir.join(file)).unwrap();
assert!(repo
.preview_user_action(&action("file", &[]), &context)
.is_err());
}

#[test]
fn stale_refs_and_wrong_scopes_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let git = git2::Repository::init(dir.path()).unwrap();
let tree = git.treebuilder(None).unwrap().write().unwrap();
let tree = git.find_tree(tree).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let oid = git
.commit(Some("refs/heads/main"), &sig, &sig, "one", &tree, &[])
.unwrap();
let context = ActionContext {
path: dir.path().to_string_lossy().into_owned(),
target: ActionTarget::Ref {
reference: "refs/heads/main".into(),
oid: oid.to_string(),
},
};
let repo = Repo::discover(dir.path()).unwrap();
assert!(repo
.preview_user_action(&action("ref", &["{ref}", "{oid}"]), &context)
.is_ok());
assert!(repo
.preview_user_action(&action("file", &[]), &context)
.is_err());
git.commit(
Some("refs/heads/main"),
&sig,
&sig,
"two",
&tree,
&[&git.find_commit(oid).unwrap()],
)
.unwrap();
let repo = Repo::discover(dir.path()).unwrap();
assert!(repo
.preview_user_action(&action("ref", &[]), &context)
.is_err());
}
}
Loading
Loading