feat: redirect git invocations to jj inside jj repos
fuckgit installs as a Cargo bin literally named `git` (via `[[bin]] name = "git"`). When invoked inside a jj worktree by an AI agent, it prints the equivalent `jj` subcommand plus the live `jj help <sub>` output to stderr and exits 2 — the AI sees a clear failure and the right correction. Outside a jj repo, or with no intercept marker, it is a transparent passthrough. The intercept gate fires only when: - effective cwd (after honoring `git -C`) is inside a `.jj` worktree - FUCKGIT_INTERCEPT=1 or an AI marker (CLAUDECODE) is set - subcommand is not on the plumbing allowlist (rev-parse, cat-file, for-each-ref, ...) - GIT_DIR / GIT_EXEC_PATH / CARGO are not set - no top-level --git-dir= / --work-tree= flag is present Otherwise we walk PATH, skip self by canonicalized current_exe(), and exec() the first other `git` we find — preserving signals, exit code, and TTY ownership. Escape hatches: FUCKGIT_PASSTHROUGH=1 env var, --fucking-git flag (stripped before exec). FUCKGIT_DEPTH guard aborts at depth >= 2 to prevent self-recursion. Linux and macOS, zero runtime dependencies.
This commit is contained in:
12 files changed
+781
No files matched your search
+163
@@ -0,0 +1,163 @@
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct ParsedArgv {
|
||||
pub effective_cwd: PathBuf,
|
||||
pub has_git_dir_or_work_tree: bool,
|
||||
pub subcommand: Option<String>,
|
||||
pub is_version_query: bool,
|
||||
}
|
||||
|
||||
const BOOL_TOP_FLAGS: &[&str] = &[
|
||||
"-p",
|
||||
"-P",
|
||||
"--paginate",
|
||||
"--no-pager",
|
||||
"--bare",
|
||||
"--no-replace-objects",
|
||||
"--html-path",
|
||||
"--man-path",
|
||||
"--info-path",
|
||||
];
|
||||
|
||||
pub fn parse_top_level(argv: &[OsString]) -> ParsedArgv {
|
||||
let mut effective_cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let mut has_git_dir_or_work_tree = false;
|
||||
let mut subcommand: Option<String> = None;
|
||||
let mut is_version_query = false;
|
||||
|
||||
let mut i = 0;
|
||||
while i < argv.len() {
|
||||
let arg = &argv[i];
|
||||
|
||||
if arg == "-C" {
|
||||
if let Some(p) = argv.get(i + 1) {
|
||||
effective_cwd = effective_cwd.join(Path::new(p));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if arg == "-c" {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if arg == "--git-dir" || arg == "--work-tree" {
|
||||
has_git_dir_or_work_tree = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(s) = arg.to_str() else { break };
|
||||
|
||||
if s.starts_with("--git-dir=") || s.starts_with("--work-tree=") {
|
||||
has_git_dir_or_work_tree = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if s.starts_with("--namespace=")
|
||||
|| s.starts_with("--config-env=")
|
||||
|| s.starts_with("--exec-path=")
|
||||
|| s == "--exec-path"
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if BOOL_TOP_FLAGS.contains(&s) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if s == "--version" || s == "-v" {
|
||||
is_version_query = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if s == "--help" || s == "-h" {
|
||||
subcommand = Some("help".to_string());
|
||||
break;
|
||||
}
|
||||
|
||||
if !s.starts_with('-') {
|
||||
subcommand = Some(s.to_string());
|
||||
break;
|
||||
}
|
||||
|
||||
// Unknown leading flag — bail out; it presumably belongs to a subcommand
|
||||
// we couldn't identify.
|
||||
break;
|
||||
}
|
||||
|
||||
ParsedArgv {
|
||||
effective_cwd,
|
||||
has_git_dir_or_work_tree,
|
||||
subcommand,
|
||||
is_version_query,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> ParsedArgv {
|
||||
let owned: Vec<OsString> = args.iter().map(OsString::from).collect();
|
||||
parse_top_level(&owned)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_subcommand() {
|
||||
let p = parse(&["status"]);
|
||||
assert_eq!(p.subcommand.as_deref(), Some("status"));
|
||||
assert!(!p.is_version_query);
|
||||
assert!(!p.has_git_dir_or_work_tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_query() {
|
||||
let p = parse(&["--version"]);
|
||||
assert!(p.is_version_query);
|
||||
let p = parse(&["-v"]);
|
||||
assert!(p.is_version_query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_capital_c_changes_cwd() {
|
||||
let p = parse(&["-C", "/tmp", "status"]);
|
||||
assert_eq!(p.subcommand.as_deref(), Some("status"));
|
||||
assert_eq!(p.effective_cwd, PathBuf::from("/tmp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_dir_forces_passthrough_flag() {
|
||||
let p = parse(&["--git-dir=/foo", "status"]);
|
||||
assert!(p.has_git_dir_or_work_tree);
|
||||
|
||||
let p = parse(&["--work-tree", "/foo", "status"]);
|
||||
assert!(p.has_git_dir_or_work_tree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_kv_consumes_two_args() {
|
||||
let p = parse(&["-c", "user.name=Foo", "log"]);
|
||||
assert_eq!(p.subcommand.as_deref(), Some("log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_skipped() {
|
||||
let p = parse(&["--paginate", "log"]);
|
||||
assert_eq!(p.subcommand.as_deref(), Some("log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_short_and_long() {
|
||||
assert_eq!(parse(&["--help"]).subcommand.as_deref(), Some("help"));
|
||||
assert_eq!(parse(&["-h"]).subcommand.as_deref(), Some("help"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub fn in_jj_repo(start: &Path) -> bool {
|
||||
let mut cur = Some(start);
|
||||
while let Some(dir) = cur {
|
||||
if dir.join(".jj").is_dir() {
|
||||
return true;
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"fuckgit-detect-{}-{name}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_jj_in_self() {
|
||||
let root = tmp("self");
|
||||
fs::create_dir_all(root.join(".jj")).unwrap();
|
||||
assert!(in_jj_repo(&root));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_jj_in_parent() {
|
||||
let root = tmp("parent");
|
||||
fs::create_dir_all(root.join(".jj")).unwrap();
|
||||
let nested = root.join("a/b/c");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
assert!(in_jj_repo(&nested));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_jj_returns_false() {
|
||||
let root = tmp("nojj");
|
||||
assert!(!in_jj_repo(&root));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::mapping;
|
||||
|
||||
pub fn redirect(subcommand: Option<&str>) {
|
||||
let stderr = io::stderr();
|
||||
let use_color = stderr.is_terminal();
|
||||
let mut h = stderr.lock();
|
||||
|
||||
let red = if use_color { "\x1b[1;31m" } else { "" };
|
||||
let bold = if use_color { "\x1b[1m" } else { "" };
|
||||
let dim = if use_color { "\x1b[2m" } else { "" };
|
||||
let reset = if use_color { "\x1b[0m" } else { "" };
|
||||
|
||||
let _ = writeln!(h, "{red}Don't use git. Use jj.{reset}");
|
||||
let _ = writeln!(h);
|
||||
|
||||
match subcommand.and_then(mapping::lookup) {
|
||||
Some(m) => {
|
||||
let _ = writeln!(
|
||||
h,
|
||||
" {bold}git {sub}{reset} → {bold}{jj}{reset}",
|
||||
sub = m.git_subcommand,
|
||||
jj = m.jj_equivalent,
|
||||
);
|
||||
if !m.note.is_empty() {
|
||||
let _ = writeln!(h, " {dim}{note}{reset}", note = m.note);
|
||||
}
|
||||
|
||||
if let Some(help_sub) = m.help_subcommand
|
||||
&& let Ok(out) = Command::new("jj").args(["help", help_sub]).output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let _ = writeln!(h);
|
||||
let _ = writeln!(h, "{dim}--- jj help {help_sub} ---{reset}");
|
||||
let _ = h.write_all(&out.stdout);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
match subcommand {
|
||||
Some(sub) if !sub.is_empty() => {
|
||||
let _ = writeln!(
|
||||
h,
|
||||
" {bold}git {sub}{reset} has no listed jj equivalent.",
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let _ = writeln!(h, " Run {bold}jj help{reset} to see what jj offers.");
|
||||
}
|
||||
}
|
||||
if let Ok(out) = Command::new("jj").arg("help").output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let _ = writeln!(h);
|
||||
let _ = writeln!(h, "{dim}--- jj help ---{reset}");
|
||||
let _ = h.write_all(&out.stdout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(h);
|
||||
let _ = writeln!(
|
||||
h,
|
||||
"{dim}Override with FUCKGIT_PASSTHROUGH=1 or git --fucking-git <args...>{reset}"
|
||||
);
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
#[cfg(not(unix))]
|
||||
compile_error!("fuckgit currently supports only Linux and macOS");
|
||||
|
||||
mod argv;
|
||||
mod detect;
|
||||
mod educate;
|
||||
mod mapping;
|
||||
mod passthrough;
|
||||
|
||||
use std::env;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::process;
|
||||
|
||||
const AI_MARKERS: &[&str] = &["CLAUDECODE"];
|
||||
|
||||
const PLUMBING: &[&str] = &[
|
||||
"rev-parse",
|
||||
"symbolic-ref",
|
||||
"for-each-ref",
|
||||
"cat-file",
|
||||
"update-ref",
|
||||
"hash-object",
|
||||
"ls-files",
|
||||
"ls-tree",
|
||||
"merge-base",
|
||||
"show-ref",
|
||||
"name-rev",
|
||||
"var",
|
||||
"credential",
|
||||
"check-ref-format",
|
||||
"version",
|
||||
];
|
||||
|
||||
fn main() -> ! {
|
||||
bump_recursion_guard_or_die();
|
||||
|
||||
let mut argv: Vec<OsString> = env::args_os().skip(1).collect();
|
||||
let had_force_flag = strip_flag(&mut argv, "--fucking-git");
|
||||
|
||||
if had_force_flag
|
||||
|| env_is_one("FUCKGIT_PASSTHROUGH")
|
||||
|| env::var_os("GIT_DIR").is_some()
|
||||
|| env::var_os("GIT_EXEC_PATH").is_some()
|
||||
|| env::var_os("CARGO").is_some()
|
||||
{
|
||||
passthrough::exec_real_git(&argv);
|
||||
}
|
||||
|
||||
let parsed = argv::parse_top_level(&argv);
|
||||
|
||||
if parsed.has_git_dir_or_work_tree || parsed.is_version_query {
|
||||
passthrough::exec_real_git(&argv);
|
||||
}
|
||||
|
||||
if let Some(sub) = parsed.subcommand.as_deref()
|
||||
&& PLUMBING.contains(&sub)
|
||||
{
|
||||
passthrough::exec_real_git(&argv);
|
||||
}
|
||||
|
||||
if !detect::in_jj_repo(&parsed.effective_cwd) {
|
||||
passthrough::exec_real_git(&argv);
|
||||
}
|
||||
|
||||
if !intercept_gate_open() {
|
||||
passthrough::exec_real_git(&argv);
|
||||
}
|
||||
|
||||
educate::redirect(parsed.subcommand.as_deref());
|
||||
process::exit(2);
|
||||
}
|
||||
|
||||
fn env_is_one(key: &str) -> bool {
|
||||
env::var_os(key).as_deref() == Some(OsStr::new("1"))
|
||||
}
|
||||
|
||||
fn intercept_gate_open() -> bool {
|
||||
if env_is_one("FUCKGIT_INTERCEPT") {
|
||||
return true;
|
||||
}
|
||||
AI_MARKERS.iter().any(|k| env::var_os(k).is_some())
|
||||
}
|
||||
|
||||
fn strip_flag(argv: &mut Vec<OsString>, flag: &str) -> bool {
|
||||
let before = argv.len();
|
||||
argv.retain(|a| a != flag);
|
||||
argv.len() != before
|
||||
}
|
||||
|
||||
fn bump_recursion_guard_or_die() {
|
||||
let depth: u32 = env::var("FUCKGIT_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
if depth >= 2 {
|
||||
eprintln!("fuckgit: recursion detected (depth={depth}); aborting");
|
||||
process::exit(70);
|
||||
}
|
||||
// SAFETY: single-threaded at startup; child inherits the bumped value.
|
||||
unsafe { env::set_var("FUCKGIT_DEPTH", (depth + 1).to_string()) };
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
pub struct Mapping {
|
||||
pub git_subcommand: &'static str,
|
||||
pub jj_equivalent: &'static str,
|
||||
pub help_subcommand: Option<&'static str>,
|
||||
pub note: &'static str,
|
||||
}
|
||||
|
||||
pub static MAPPINGS: &[Mapping] = &[
|
||||
Mapping {
|
||||
git_subcommand: "status",
|
||||
jj_equivalent: "jj st",
|
||||
help_subcommand: Some("status"),
|
||||
note: "jj has no staging area; `jj st` shows the working-copy commit's diff.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "log",
|
||||
jj_equivalent: "jj log",
|
||||
help_subcommand: Some("log"),
|
||||
note: "Default revset is the trunk-and-mutable view, not all-history.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "commit",
|
||||
jj_equivalent: "jj commit (or `jj describe` + `jj new`)",
|
||||
help_subcommand: Some("commit"),
|
||||
note: "jj auto-snapshots; `describe` sets @'s message, `new` starts the next change.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "add",
|
||||
jj_equivalent: "(no equivalent — jj auto-snapshots)",
|
||||
help_subcommand: None,
|
||||
note: "jj tracks the working copy automatically. Edit `.gitignore` to untrack.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "checkout",
|
||||
jj_equivalent: "jj edit / jj new",
|
||||
help_subcommand: Some("edit"),
|
||||
note: "`jj edit <rev>` to amend in place; `jj new <rev>` to start a child change.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "switch",
|
||||
jj_equivalent: "jj edit / jj new",
|
||||
help_subcommand: Some("edit"),
|
||||
note: "Same options as the checkout replacement.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "restore",
|
||||
jj_equivalent: "jj restore",
|
||||
help_subcommand: Some("restore"),
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "branch",
|
||||
jj_equivalent: "jj bookmark",
|
||||
help_subcommand: Some("bookmark"),
|
||||
note: "jj calls them bookmarks. They don't auto-move with new commits.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "merge",
|
||||
jj_equivalent: "jj new <rev1> <rev2>",
|
||||
help_subcommand: Some("new"),
|
||||
note: "jj merges by creating a multi-parent change — no merge command.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "rebase",
|
||||
jj_equivalent: "jj rebase",
|
||||
help_subcommand: Some("rebase"),
|
||||
note: "Rebases entire change graphs in one go; conflicts are first-class.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "pull",
|
||||
jj_equivalent: "jj git fetch (then `jj rebase` if needed)",
|
||||
help_subcommand: Some("git"),
|
||||
note: "jj does not auto-merge on fetch.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "push",
|
||||
jj_equivalent: "jj git push",
|
||||
help_subcommand: Some("git"),
|
||||
note: "Push bookmarks to the colocated git remote.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "fetch",
|
||||
jj_equivalent: "jj git fetch",
|
||||
help_subcommand: Some("git"),
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "clone",
|
||||
jj_equivalent: "jj git clone",
|
||||
help_subcommand: Some("git"),
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "init",
|
||||
jj_equivalent: "jj git init --colocate",
|
||||
help_subcommand: Some("git"),
|
||||
note: "Use --colocate to keep a working .git alongside .jj.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "diff",
|
||||
jj_equivalent: "jj diff",
|
||||
help_subcommand: Some("diff"),
|
||||
note: "Default compares @ to its parent.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "show",
|
||||
jj_equivalent: "jj show",
|
||||
help_subcommand: Some("show"),
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "reset",
|
||||
jj_equivalent: "jj abandon / jj restore",
|
||||
help_subcommand: Some("abandon"),
|
||||
note: "`abandon` drops a change; `restore` reverts file content from another rev.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "revert",
|
||||
jj_equivalent: "jj backout",
|
||||
help_subcommand: Some("backout"),
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "cherry-pick",
|
||||
jj_equivalent: "jj duplicate / jj rebase -r",
|
||||
help_subcommand: Some("duplicate"),
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "stash",
|
||||
jj_equivalent: "(not needed — just `jj new`)",
|
||||
help_subcommand: None,
|
||||
note: "Prior change stays in the graph as its own commit.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "tag",
|
||||
jj_equivalent: "jj bookmark / fall back to `jj git`",
|
||||
help_subcommand: None,
|
||||
note: "jj has no native tag concept; use bookmarks or operate via the git backend.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "blame",
|
||||
jj_equivalent: "jj file annotate",
|
||||
help_subcommand: Some("file"),
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "remote",
|
||||
jj_equivalent: "jj git remote",
|
||||
help_subcommand: Some("git"),
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "bisect",
|
||||
jj_equivalent: "(no built-in — use `jj log` + `jj edit`)",
|
||||
help_subcommand: None,
|
||||
note: "",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "config",
|
||||
jj_equivalent: "jj config",
|
||||
help_subcommand: Some("config"),
|
||||
note: "Different schema; see `jj config list`.",
|
||||
},
|
||||
Mapping {
|
||||
git_subcommand: "reflog",
|
||||
jj_equivalent: "jj op log",
|
||||
help_subcommand: Some("operation"),
|
||||
note: "Captures full repo state per op, not just HEAD.",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn lookup(sub: &str) -> Option<&'static Mapping> {
|
||||
MAPPINGS.iter().find(|m| m.git_subcommand == sub)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{self, Command};
|
||||
|
||||
pub fn exec_real_git(argv: &[OsString]) -> ! {
|
||||
let real = find_real_git().unwrap_or_else(|| {
|
||||
eprintln!("fuckgit: no real `git` found on PATH (other than self)");
|
||||
process::exit(127);
|
||||
});
|
||||
let err = Command::new(&real).args(argv).exec();
|
||||
eprintln!("fuckgit: failed to exec {}: {err}", real.display());
|
||||
process::exit(127);
|
||||
}
|
||||
|
||||
fn find_real_git() -> Option<PathBuf> {
|
||||
let me = env::current_exe().ok().and_then(|p| p.canonicalize().ok());
|
||||
let path = env::var_os("PATH")?;
|
||||
for dir in env::split_paths(&path) {
|
||||
let candidate = dir.join("git");
|
||||
if !candidate.is_file() {
|
||||
continue;
|
||||
}
|
||||
if !is_executable(&candidate) {
|
||||
continue;
|
||||
}
|
||||
if let Some(canon) = candidate.canonicalize().ok()
|
||||
&& let Some(me) = &me
|
||||
&& &canon == me
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Some(candidate);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
path.metadata()
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Reference in new issue
Block a user