diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d6341c..f9d93d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,41 @@ env: RUST_BACKTRACE: 1 jobs: + version: + name: Check version is not already tagged + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Reject an existing version tag + run: | + python3 - <<'PY' + import subprocess + import tomllib + + with open("Cargo.toml", "rb") as manifest: + version = tomllib.load(manifest)["package"]["version"] + tag = f"v{version}" + tags = subprocess.check_output(["git", "tag", "--list"], text=True).splitlines() + if tag in tags: + print( + f"::error file=Cargo.toml::Tag {tag} already exists on GitHub. " + "Bump package.version in Cargo.toml to a new version and update Cargo.lock." + ) + raise SystemExit(1) + print(f"Version {version} is available: tag {tag} does not exist.") + PY + format: name: Rust and TOML formatting + needs: version + # Tag-triggered release checks intentionally skip the PR-only version job. + if: >- + ${{ !cancelled() && (needs.version.result == 'success' || + (github.event_name != 'pull_request' && needs.version.result == 'skipped')) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -36,6 +69,9 @@ jobs: check: name: Check, Clippy, tests, and binary (${{ matrix.os }}) + needs: format + # Allow the skipped version ancestor on releases, but require formatting to pass. + if: ${{ !cancelled() && needs.format.result == 'success' }} strategy: fail-fast: false matrix: diff --git a/AGENTS.md b/AGENTS.md index a6c5d77..05152d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,8 @@ one executable for macOS and Linux. Read README.md before changing its behavior. examples, and section coverage equivalent. Preserve their language-switch links. - Use the exact toolchain in rust-toolchain.toml. Keep Cargo.lock checked in. - Building requires a C compiler for vendored libgit2 and SQLite. The distributed executable - does not require a separate Rust or Git installation. + needs no separate Rust installation, and its built-in commands need no system Git. + Only the explicit `filetrail git` passthrough and its integration tests require Git on PATH. - Run `cargo fmt --all`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked --all-targets`, and `cargo test --locked --doc`. - Completion integration tests require Bash, Zsh, and Fish on PATH. @@ -114,4 +115,10 @@ Only run `filetrail commit` when a commit is within the user's requested scope. Omit `-m` to use the generated message. Do not run `resolve --use-source`, enable deletion, or install a service merely to make a test or diagnostic pass. +`filetrail cd` jumps to the repository root when the Bash, Zsh, or Fish integration +is loaded; `command filetrail cd` prints its path. `filetrail git ` runs +system Git in that root under the operation lock. Put `--data-dir` before `git`. +This explicit passthrough follows normal Git behavior, including unmanaged files; +only run mutations such as commits or pushes when the user requests them. + Report actual test outcomes and distinguish local checks from GitHub-hosted CI. diff --git a/Cargo.lock b/Cargo.lock index d0de3aa..b50d8b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,7 +302,7 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filetrail" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "blake3", diff --git a/Cargo.toml b/Cargo.toml index abd2f90..519d673 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ edition = "2024" license = "MIT" name = "filetrail" repository = "https://github.com/RinChanNOWWW/FileTrail" -version = "0.1.0" +version = "0.2.0" [dependencies] anyhow = "1.0" diff --git a/README.md b/README.md index 1632df1..c2a432f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ lets you review and commit them on your terms. Use it for dotfiles, scripts, notes, or other files spread across your machine. Keep separate macOS and Linux configurations in the same repository. Everything -runs from a single executable, with no separate Git installation required. +runs from a single executable. Built-in synchronization and version-control commands +need no separate Git installation; the optional `filetrail git` passthrough uses system Git. ## Install @@ -30,7 +31,7 @@ with `./install.sh zsh`. It installs with Cargo, then configures that shell's completion. Open a new shell afterward. The installation root defaults to `${CARGO_HOME:-$HOME/.cargo}`; set `CARGO_INSTALL_ROOT` to override it. -## Tab completion +## Tab completion and shell integration If you installed FileTrail with `cargo install`, enable completion with: @@ -49,11 +50,12 @@ filetrail completions fish --install Run the command for the shell you use, then open a new shell. Tab completes subcommands (including `daemon` and `service` actions), options, and file paths. For example, try `filetrail da`, `filetrail daemon st`, or -`filetrail add --f`. +`filetrail add --f`. This also enables `filetrail cd` to change the current +shell's directory to the target repository. Installation preserves existing shell configuration and is safe to repeat. It uses `.zshrc` (respecting `ZDOTDIR`), `.bashrc` and Bash's active login profile, -or Fish's completion directory (respecting `XDG_CONFIG_HOME`). Home paths use +or Fish's completion and function directories (respecting `XDG_CONFIG_HOME`). Home paths use `$HOME` in the installed hooks and command output, so your username is not embedded. Paths outside Home retain their absolute location. Completion stays in sync when you upgrade the executable at the same location. Run installation @@ -228,6 +230,36 @@ filetrail resume commands still copy files while automatic synchronization is paused. Paths passed to `diff`, `commit`, and `resolve` are relative to the repository root. +## Jump to the repository and run Git + +With Bash, Zsh, or Fish integration installed, jump to the target repository: + +```sh +filetrail completions --install # Also run once when upgrading to enable directory jumping +# Open a new shell, then: +filetrail cd +``` + +This changes the current shell's directory to the repository root, even when a +`--subdir` is configured. Without shell integration, the executable prints the path; +in Bash or Zsh you can use `cd "$(command filetrail cd)"`. Use `command filetrail cd` +to print the path when integration is loaded, or add `--print0` for NUL-terminated output. + +Run any system Git command in the target repository without changing directories: + +```sh +filetrail git status +filetrail git log --oneline -10 +filetrail git push origin master +filetrail --data-dir ~/filetrail-work git push origin master +``` + +This requires `git` on PATH and uses its normal configuration, credentials, and hooks. +Put FileTrail's `--data-dir` before `git`; Git arguments, input/output, and exit codes +are passed through. Synchronization waits while the Git command runs. No automatic +sync, staging, or commit is added. `filetrail git commit` follows normal Git staging +and can include any staged file; `filetrail commit` remains limited to managed changes. + ## Resolve conflicts FileTrail reports a conflict if a destination differs from an existing source on diff --git a/README_zh.md b/README_zh.md index 8ad72e6..e50ef93 100644 --- a/README_zh.md +++ b/README_zh.md @@ -6,7 +6,8 @@ FileTrail 是一个支持 Git 版本管理的文件同步工具。它监听你 将变化同步到本地 Git 仓库,让你查看差异并决定何时提交。 你可以用它管理分散在电脑上的 dotfiles、脚本、笔记等文件,也可以在同一仓库中 -分别保存 macOS 和 Linux 的配置。所有功能由一个可执行文件完成,无需额外安装 Git。 +分别保存 macOS 和 Linux 的配置。所有功能由一个可执行文件提供。内置同步和版本管理命令 +无需额外安装 Git;可选的 `filetrail git` 透传命令使用系统 Git。 ## 安装 @@ -27,7 +28,7 @@ filetrail --help 它先通过 Cargo 安装,再配置所选 shell 的补全,完成后重新打开 shell 即可。 安装根目录默认为 `${CARGO_HOME:-$HOME/.cargo}`,可通过 `CARGO_INSTALL_ROOT` 覆盖。 -## Tab 补全 +## Tab 补全与 shell 集成 如果使用 `cargo install` 安装 FileTrail,执行以下命令启用补全: @@ -46,9 +47,10 @@ filetrail completions fish --install 执行你所用 shell 对应的命令,然后重新打开 shell。Tab 可以补全子命令 (包括 `daemon` 和 `service` 的操作)、选项及文件路径。例如: `filetrail da`、`filetrail daemon st`、`filetrail add --f`。 +这也会启用 `filetrail cd`,用于将当前 shell 的工作目录切换到目标仓库。 安装会保留已有 shell 配置,重复执行不会添加重复配置。配置位置为 `.zshrc` -(遵循 `ZDOTDIR`)、`.bashrc` 和 Bash 当前使用的登录配置文件,或 Fish 的补全目录 +(遵循 `ZDOTDIR`)、`.bashrc` 和 Bash 当前使用的登录配置文件,或 Fish 的补全与函数目录 (遵循 `XDG_CONFIG_HOME`)。安装的补全配置和命令输出使用 `$HOME` 表示 Home 路径, 不写入用户名;Home 以外的路径保留绝对位置。在相同位置升级可执行文件后,补全会同步更新; 移动可执行文件后需重新安装补全。若要移除补全,删除安装命令所列配置文件中 @@ -210,6 +212,36 @@ filetrail resume `resume` 会补齐暂停期间的变化。显式执行 `sync` 和 `add` 时,即使自动同步已暂停, 仍会复制文件。`diff`、`commit` 和 `resolve` 接收的路径都相对于仓库根目录。 +## 跳转到仓库与执行 Git + +安装 Bash、Zsh 或 Fish 集成后,可以直接跳转到目标仓库: + +```sh +filetrail completions --install # 从旧版升级时也执行一次,以启用目录跳转 +# 重新打开 shell 后: +filetrail cd +``` + +这会将当前 shell 的工作目录切换到仓库根目录,即使配置了 `--subdir` 也是如此。 +未加载 shell 集成时,可执行文件只输出路径;在 Bash 或 Zsh 中可以使用 +`cd "$(command filetrail cd)"`。加载集成后,使用 `command filetrail cd` 仍可输出路径, +或加上 `--print0` 输出以 NUL 结尾的路径。 + +无需切换目录,也可以直接在目标仓库执行任意系统 Git 命令: + +```sh +filetrail git status +filetrail git log --oneline -10 +filetrail git push origin master +filetrail --data-dir ~/filetrail-work git push origin master +``` + +此功能要求 PATH 中存在 `git`,并使用 Git 原有的配置、凭据和 hooks。 +FileTrail 的 `--data-dir` 应放在 `git` 之前;Git 参数、输入输出和退出码会透传。 +Git 命令执行期间,同步操作会等待。该命令不会额外执行同步、暂存或提交。 +`filetrail git commit` 按 Git 的正常暂存规则工作,可以包含任意已暂存文件; +`filetrail commit` 仍然只提交受管理的修改。 + ## 处理冲突 首次同步时目标与已有来源内容不同,或者你在 FileTrail 之外修改了目标文件, diff --git a/src/completion.rs b/src/completion.rs index cab777f..3278347 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -37,10 +37,13 @@ pub fn install(shell: Shell, binary: &Path) -> Result> { vec![home.join(".bashrc"), login] } Shell::Zsh => vec![environment_directory("ZDOTDIR", &home).join(".zshrc")], - Shell::Fish => vec![ - environment_directory("XDG_CONFIG_HOME", &home.join(".config")) - .join("fish/completions/filetrail.fish"), - ], + Shell::Fish => { + let fish = environment_directory("XDG_CONFIG_HOME", &home.join(".config")).join("fish"); + vec![ + fish.join("completions/filetrail.fish"), + fish.join("functions/filetrail.fish"), + ] + } _ => bail!("automatic installation supports bash, zsh, and fish only"), }; @@ -126,6 +129,18 @@ fn executable_expression(shell: Shell, binary: &Path, home: &Path) -> Result Result { + let template = match shell { + Shell::Bash | Shell::Zsh => include_str!("shell_integration.sh"), + Shell::Fish => include_str!("shell_integration.fish"), + _ => return Ok(String::new()), + }; + let home = dirs::home_dir().context("cannot determine home directory")?; + Ok(template.replace("@FILETRAIL@", &executable_expression(shell, binary, &home)?)) +} + fn hook(shell: Shell, binary: &Path, home: &Path) -> Result { let quoted = executable_expression(shell, binary, home)?; let body = match shell { diff --git a/src/git.rs b/src/git.rs index 0095bb0..af714d6 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,4 +1,7 @@ +use std::ffi::OsString; use std::path::Path; +use std::process::Command; +use std::process::ExitStatus; use anyhow::Context; use anyhow::Result; @@ -22,6 +25,19 @@ pub fn open(config: &Config) -> Result { Ok(repository) } +/// Explicit passthrough only; built-in Git operations continue to use libgit2. +pub fn run(store: &Store, args: &[OsString]) -> Result { + // Keep sync and retarget from changing the working tree while Git is running. + let _lock = store.lock()?; + let config = store.config()?; + open(&config)?; + Command::new("git") + .args(args) + .current_dir(&config.repository) + .status() + .context("cannot run system Git; install git and make sure it is on PATH") +} + pub fn ensure_idle(repository: &Repository) -> Result<()> { if repository.state() != RepositoryState::Clean || repository.index()?.has_conflicts() { bail!("repository has an ongoing Git operation or unresolved conflicts"); diff --git a/src/main.rs b/src/main.rs index 90928e7..a6574c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,11 @@ #![forbid(unsafe_code)] +use std::ffi::OsString; use std::fs; +use std::io::Write; +use std::os::unix::process::ExitStatusExt; use std::path::PathBuf; +use std::process::ExitCode; use std::thread; use std::time::Duration; @@ -90,6 +94,18 @@ enum Commands { Resume, /// Show repository changes, ownership, conflicts, and daemon status. Status, + /// Jump to the target repository with shell integration; otherwise print its path. + Cd { + /// Terminate the path with NUL for shell integration and scripts. + #[arg(long)] + print0: bool, + }, + /// Run system Git in the target repository. Put FileTrail options before git. + #[command(disable_help_flag = true, disable_version_flag = true)] + Git { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Show staged and working tree diffs, including untracked file contents. Diff { paths: Vec, @@ -113,7 +129,7 @@ enum Commands { }, /// Validate configuration, Git state, source availability, and mappings. Doctor, - /// Generate completions or install Tab completion for Bash, Zsh, or Fish. + /// Generate completions or install Tab completion and directory jumping for Bash, Zsh, or Fish. Completions { /// Shell to generate/install for; --install defaults to $SHELL. #[arg(required_unless_present = "install")] @@ -151,14 +167,39 @@ enum ServiceCommands { Show, } -fn main() { - if let Err(error) = execute(Cli::parse()) { - eprintln!("error: {error:#}"); - std::process::exit(1); +fn main() -> ExitCode { + let cli = Cli::try_parse_from(git_passthrough_arguments(std::env::args_os().collect())) + .unwrap_or_else(|error| error.exit()); + match execute(cli) { + Ok(code) => code, + Err(error) => { + eprintln!("error: {error:#}"); + ExitCode::FAILURE + } + } +} + +fn git_passthrough_arguments(mut arguments: Vec) -> Vec { + // Clap normally recognizes global options before the first trailing value. + // Insert a delimiter so *every* argument after `git` belongs to Git, including + // a leading --data-dir or --. Only inspect FileTrail's root-level options. + let mut index = 1; + while let Some(argument) = arguments.get(index) { + if argument == "--data-dir" { + index += 2; + } else if argument.as_encoded_bytes().starts_with(b"--data-dir=") { + index += 1; + } else { + if argument == "git" { + arguments.insert(index + 1, OsString::from("--")); + } + break; + } } + arguments } -fn execute(cli: Cli) -> Result<()> { +fn execute(cli: Cli) -> Result { if let Commands::Completions { shell, install } = cli.command { let shell = shell.or_else(Shell::from_env).context( "cannot detect shell; specify bash, zsh, or fish, e.g. completions zsh --install", @@ -168,7 +209,9 @@ fn execute(cli: Cli) -> Result<()> { for path in paths { println!("Configured {}", filetrail::completion::display_path(&path)); } - println!("{shell} Tab completion installed. Open a new shell to activate it."); + println!( + "{shell} Tab completion installed, including filetrail cd integration. Open a new shell to activate it." + ); } else { clap_complete::generate( shell, @@ -176,8 +219,12 @@ fn execute(cli: Cli) -> Result<()> { "filetrail", &mut std::io::stdout(), ); + print!( + "{}", + filetrail::completion::shell_integration(shell, &std::env::current_exe()?)? + ); } - return Ok(()); + return Ok(ExitCode::SUCCESS); } let store = Store::new(data_root(cli.data_dir)?)?; // Lifecycle commands share a separate lock: never wait for a daemon while @@ -417,6 +464,25 @@ fn execute(cli: Cli) -> Result<()> { filetrail::daemon::request(&store, "status").unwrap_or_else(|_| "stopped".into()) ); } + Commands::Cd { print0 } => { + let _lock = store.lock()?; + let config = store.config()?; + filetrail::git::open(&config)?; + let path = config + .repository + .to_str() + .context("repository path is not UTF-8")?; + let mut stdout = std::io::stdout().lock(); + stdout.write_all(path.as_bytes())?; + stdout.write_all(if print0 { b"\0" } else { b"\n" })?; + } + Commands::Git { args } => { + let status = filetrail::git::run(&store, &args)?; + let code = status + .code() + .unwrap_or_else(|| 128 + status.signal().unwrap_or(1)); + return Ok(ExitCode::from(u8::try_from(code).unwrap_or(1))); + } Commands::Diff { paths } => print!("{}", filetrail::git::diff(&store, &paths)?), Commands::Commit { message, paths } => println!( "{}", @@ -486,7 +552,7 @@ fn execute(cli: Cli) -> Result<()> { } Commands::Completions { .. } => unreachable!(), } - Ok(()) + Ok(ExitCode::SUCCESS) } fn print_report(report: filetrail::sync::Report) -> Result<()> { @@ -515,12 +581,15 @@ fn data_root(override_dir: Option) -> Result { #[cfg(test)] mod tests { + use std::ffi::OsString; use std::path::PathBuf; use clap::Parser; use super::Cli; + use super::Commands; use super::data_root; + use super::git_passthrough_arguments; #[test] fn add_rejects_custom_targets() { @@ -554,4 +623,34 @@ mod tests { .is_err() ); } + + #[test] + fn git_arguments_are_passed_through_including_options_and_separators() { + for args in [ + vec![], + vec!["--help"], + vec!["--version"], + vec!["--data-dir", "passed-to-git"], + vec!["--", "status"], + vec!["-c", "user.name=Test User", "status", "--short"], + vec!["push", "origin", "master"], + vec!["diff", "--", "--data-dir", "a file"], + vec!["show", "--help", "--version"], + ] { + let mut arguments = vec!["filetrail", "--data-dir", "/tmp/profile", "git"]; + arguments.extend(&args); + let cli = Cli::try_parse_from(git_passthrough_arguments( + arguments.into_iter().map(OsString::from).collect(), + )) + .unwrap(); + assert_eq!(cli.data_dir, Some(PathBuf::from("/tmp/profile"))); + let Commands::Git { args: parsed } = cli.command else { + panic!("expected git command"); + }; + assert_eq!( + parsed, + args.into_iter().map(OsString::from).collect::>() + ); + } + } } diff --git a/src/shell_integration.fish b/src/shell_integration.fish new file mode 100644 index 0000000..83874bc --- /dev/null +++ b/src/shell_integration.fish @@ -0,0 +1,32 @@ + +function filetrail + set -l filetrail_command '' + set -l filetrail_skip 0 + for filetrail_arg in $argv + if test "$filetrail_skip" = 1 + set filetrail_skip 0 + continue + end + switch "$filetrail_arg" + case --data-dir + set filetrail_skip 1 + case '--data-dir=*' -- + case -h --help -V --version --print0 + command @FILETRAIL@ $argv + return $status + case '*' + if test -z "$filetrail_command" + set filetrail_command "$filetrail_arg" + end + end + end + if test "$filetrail_command" = cd + set -l filetrail_target (command @FILETRAIL@ $argv --print0 | string split0) + if test (count $filetrail_target) != 1 + return 1 + end + builtin cd -- "$filetrail_target" + else + command @FILETRAIL@ $argv + end +end diff --git a/src/shell_integration.sh b/src/shell_integration.sh new file mode 100644 index 0000000..fa1c8c2 --- /dev/null +++ b/src/shell_integration.sh @@ -0,0 +1,29 @@ + +filetrail() { + local _filetrail_command='' _filetrail_skip=0 _filetrail_arg _filetrail_target + for _filetrail_arg in "$@"; do + if [ "$_filetrail_skip" = 1 ]; then + _filetrail_skip=0 + continue + fi + case $_filetrail_arg in + --data-dir) _filetrail_skip=1 ;; + --data-dir=*|--) ;; + -h|--help|-V|--version|--print0) + command @FILETRAIL@ "$@" + return $? + ;; + *) + if [ -z "$_filetrail_command" ]; then + _filetrail_command=$_filetrail_arg + fi + ;; + esac + done + if [ "$_filetrail_command" = cd ]; then + IFS= read -r -d '' _filetrail_target < <(command @FILETRAIL@ "$@" --print0) || return 1 + builtin cd -- "$_filetrail_target" + else + command @FILETRAIL@ "$@" + fi +} diff --git a/tests/workflow.rs b/tests/workflow.rs index 065b5cf..66b1d63 100644 --- a/tests/workflow.rs +++ b/tests/workflow.rs @@ -123,6 +123,8 @@ fn completion_generation_includes_nested_commands_without_initialization() { .unwrap(), ); for expected in [ + "cd", + "git", "daemon", "restart", "service", @@ -147,6 +149,7 @@ fn completion_installation_is_idempotent_and_preserves_existing_profiles() { ".bash_login", ".zshrc", ".config/fish/completions/filetrail.fish", + ".config/fish/functions/filetrail.fish", ] { let path = f.home.join(name); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -155,7 +158,13 @@ fn completion_installation_is_idempotent_and_preserves_existing_profiles() { for (shell, names) in [ ("bash", vec![".bashrc", ".bash_login"]), ("zsh", vec![".zshrc"]), - ("fish", vec![".config/fish/completions/filetrail.fish"]), + ( + "fish", + vec![ + ".config/fish/completions/filetrail.fish", + ".config/fish/functions/filetrail.fish", + ], + ), ] { let output = f.install(shell); assert!(output.contains("Open a new shell")); @@ -223,6 +232,7 @@ fn completion_installation_respects_overrides_symlinks_and_permissions() { .unwrap(), ); assert!(xdg.join("fish/completions/filetrail.fish").is_file()); + assert!(xdg.join("fish/functions/filetrail.fish").is_file()); assert!(!f.home.join(".zshrc").exists()); assert!(!f.home.join(".config").exists()); } @@ -397,6 +407,51 @@ fn completion_hooks_follow_home_after_relocation() { } } +#[test] +fn shell_integration_jumps_to_repository_and_preserves_other_commands() { + for shell in ["bash", "zsh", "fish"] { + let f = CompletionFixture::new(); + let repository = f + .temp + .path() + .join("target 'quoted' $cash \"double\" `literal`\nend\n"); + Repository::init(&repository).unwrap(); + let repository = fs::canonicalize(repository).unwrap(); + let store = Store::new(f.temp.path().join("state 'quoted'")).unwrap(); + store + .save_config(&Config::new(repository.clone(), "macos".into()).unwrap()) + .unwrap(); + f.install(shell); + for script in [ + "filetrail --data-dir \"$FILETRAIL_TEST_STATE\" cd || exit 1; test \"$PWD\" = \"$FILETRAIL_TEST_TARGET\"", + "filetrail cd --data-dir=\"$FILETRAIL_TEST_STATE\" || exit 1; test \"$PWD\" = \"$FILETRAIL_TEST_TARGET\"", + "filetrail cd --data-dir \"$FILETRAIL_TEST_STATE\" --help >/dev/null || exit 1; test \"$PWD\" = \"$FILETRAIL_TEST_START\"", + "filetrail --data-dir \"$FILETRAIL_TEST_STATE\" git --version || exit 1; test \"$PWD\" = \"$FILETRAIL_TEST_START\"", + "filetrail --data-dir \"$FILETRAIL_TEST_MISSING\" cd && exit 1; test \"$PWD\" = \"$FILETRAIL_TEST_START\"", + "filetrail --data-dir \"$FILETRAIL_TEST_STATE\" cd invalid && exit 1; test \"$PWD\" = \"$FILETRAIL_TEST_START\"", + ] { + let mut command = f.command(shell); + command + .env("FILETRAIL_TEST_STATE", &store.root) + .env("FILETRAIL_TEST_TARGET", &repository) + .env("FILETRAIL_TEST_START", fs::canonicalize(&f.home).unwrap()) + .env("FILETRAIL_TEST_MISSING", f.temp.path().join("missing")); + match shell { + "bash" => { + command.args(["--noprofile", "-ic", script]); + } + "zsh" => { + command.args(["-d", "-ic", script]); + } + _ => { + command.args(["-c", script]); + } + } + output_text(command.output().unwrap()); + } + } +} + #[test] fn install_wrapper_uses_cargo_then_installs_completion_only_on_success() { let f = CompletionFixture::new(); @@ -515,6 +570,8 @@ impl Fixture { fn cli(&self, args: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_filetrail")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") .arg("--data-dir") .arg(&self.store.root) .args(args) @@ -523,6 +580,165 @@ impl Fixture { } } +#[test] +fn cd_prints_repository_root_and_follows_retarget() { + let f = Fixture::new("macos", false); + let repository = fs::canonicalize(&f.repository).unwrap(); + assert_eq!( + output_text(f.cli(&["cd"])), + format!("{}\n", repository.display()) + ); + assert_eq!( + f.cli(&["cd", "--print0"]).stdout, + format!("{}\0", repository.display()).as_bytes() + ); + let next = f._temp.path().join("another repository"); + output_text(f.cli(&["retarget", next.to_str().unwrap()])); + assert_eq!( + output_text(f.cli(&["cd"])), + format!("{}\n", fs::canonicalize(&next).unwrap().display()) + ); + fs::rename(&next, f._temp.path().join("moved repository")).unwrap(); + let result = f.cli(&["cd"]); + assert!(!result.status.success()); + assert!(result.stdout.is_empty()); +} + +#[test] +fn git_runs_in_target_root_and_pushes_to_a_local_remote() { + let f = Fixture::new("macos", false); + let repository = fs::canonicalize(&f.repository).unwrap(); + assert_eq!( + output_text(f.cli(&["git", "rev-parse", "--show-toplevel"])), + format!("{}\n", repository.display()) + ); + let repo = Repository::open(&f.repository).unwrap(); + let mut index = repo.index().unwrap(); + let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); + let identity = repo.signature().unwrap(); + let commit = repo + .commit( + Some("refs/heads/master"), + &identity, + &identity, + "Test commit", + &tree, + &[], + ) + .unwrap(); + let remote_path = f._temp.path().join("remote.git"); + let remote = Repository::init_bare(&remote_path).unwrap(); + repo.remote("origin", remote_path.to_str().unwrap()) + .unwrap(); + output_text(f.cli(&["git", "push", "origin", "master"])); + assert_eq!( + remote.find_reference("refs/heads/master").unwrap().target(), + Some(commit) + ); + // Git's own nonzero exit status must survive the wrapper. + assert_eq!( + f.cli(&["git", "config", "--get", "filetrail.missing"]) + .status + .code(), + Some(1) + ); + assert_eq!( + f.cli(&["git", "rev-parse", "--verify", "refs/heads/missing"]) + .status + .code(), + Some(128) + ); +} + +#[test] +fn git_passthrough_preserves_arguments_io_exit_status_and_operation_lock() { + use std::ffi::OsString; + use std::io::Read; + use std::io::Write; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::ffi::OsStringExt; + use std::process::Stdio; + + let f = Fixture::new("macos", false); + let bin = f._temp.path().join("bin"); + fs::create_dir(&bin).unwrap(); + let git = bin.join("git"); + fs::write(&git, "#!/bin/sh\nprintf '%s\\0' \"$PWD\" \"$@\"\nprintf ready >&2\nIFS= read -r input\nprintf '%s' \"$input\"\nexit 37\n").unwrap(); + fs::set_permissions(&git, fs::Permissions::from_mode(0o755)).unwrap(); + let args = vec![ + OsString::from("-c"), + OsString::from("user.name=Quoted 'user' $cash"), + OsString::from("push"), + OsString::from("origin"), + OsString::from("master"), + OsString::from("--"), + OsString::from("--data-dir"), + OsString::from_vec(b"non-utf8-\xff".to_vec()), + ]; + let mut child = Command::new(env!("CARGO_BIN_EXE_filetrail")) + .arg("--data-dir") + .arg(&f.store.root) + .arg("git") + .args(&args) + .env("PATH", &bin) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut ready = [0; 5]; + child + .stderr + .as_mut() + .unwrap() + .read_exact(&mut ready) + .unwrap(); + assert_eq!(&ready, b"ready"); + let lock = f.store.lock_file("operation.lock").unwrap(); + assert!(FileExt::try_lock_exclusive(&lock).is_err()); + child + .stdin + .take() + .unwrap() + .write_all(b"input from caller\n") + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(37)); + let mut expected = fs::canonicalize(&f.repository) + .unwrap() + .as_os_str() + .as_bytes() + .to_vec(); + expected.push(0); + for arg in &args { + expected.extend_from_slice(arg.as_bytes()); + expected.push(0); + } + expected.extend_from_slice(b"input from caller"); + assert_eq!(output.stdout, expected); + FileExt::try_lock_exclusive(&lock).unwrap(); +} + +#[test] +fn git_reports_missing_executable_without_affecting_builtin_commands() { + let f = Fixture::new("macos", false); + let missing = f._temp.path().join("no executables"); + let command = |args: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_filetrail")) + .arg("--data-dir") + .arg(&f.store.root) + .args(args) + .env("PATH", &missing) + .output() + .unwrap() + }; + let result = command(&["git", "status"]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("install git")); + output_text(command(&["status"])); + output_text(command(&["cd"])); +} + #[test] fn platform_subdir_recursive_sync_and_idempotence() { let f = Fixture::new("macos", false);