diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a83d2db --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,100 @@ +{ + "extraKnownMarketplaces": { + "konoka": { + "source": { + "source": "github", + "repo": "ncaq/konoka", + "ref": "v5.0.4" + } + } + }, + "enabledPlugins": { + "kyosei@konoka": true, + "nix-tasuke@konoka": true + }, + "enableAllProjectMcpServers": true, + "permissions": { + "allow": [ + "Bash(actionlint:*)", + "Bash(deadnix:*)", + "Bash(editorconfig-checker:*)", + "Bash(emacs:*)", + "Bash(gh * browse:*)", + "Bash(gh * checks:*)", + "Bash(gh * clone:*)", + "Bash(gh * diff:*)", + "Bash(gh * download:*)", + "Bash(gh * list:*)", + "Bash(gh * logs:*)", + "Bash(gh * search:*)", + "Bash(gh * status:*)", + "Bash(gh * verify:*)", + "Bash(gh * view:*)", + "Bash(gh * watch:*)", + "Bash(gh api *issues/*/comments*)", + "Bash(gh api *pulls/*/comments*)", + "Bash(gh api *pulls/*/reviews*)", + "Bash(gh browse:*)", + "Bash(gh search:*)", + "Bash(gh status:*)", + "Bash(git add:*)", + "Bash(git blame:*)", + "Bash(git branch:*)", + "Bash(git checkout:*)", + "Bash(git diff:*)", + "Bash(git fetch:*)", + "Bash(git log:*)", + "Bash(git ls-files:*)", + "Bash(git mv:*)", + "Bash(git rev-parse:*)", + "Bash(git show:*)", + "Bash(git stash:*)", + "Bash(git status:*)", + "Bash(git tag:*)", + "Bash(nix build:*)", + "Bash(nix eval:*)", + "Bash(nix flake:*)", + "Bash(nix fmt:*)", + "Bash(nix search:*)", + "Bash(nix-fast-build:*)", + "Bash(nixfmt:*)", + "Bash(prettier:*)", + "Bash(shellcheck:*)", + "Bash(shfmt:*)", + "Bash(statix:*)", + "Bash(typos:*)", + "Bash(zizmor:*)", + "mcp__deepwiki", + "mcp__github__get_commit", + "mcp__github__get_file_contents", + "mcp__github__get_label", + "mcp__github__get_latest_release", + "mcp__github__get_me", + "mcp__github__get_release_by_tag", + "mcp__github__get_tag", + "mcp__github__get_team_members", + "mcp__github__get_teams", + "mcp__github__issue_read", + "mcp__github__list_branches", + "mcp__github__list_commits", + "mcp__github__list_issue_types", + "mcp__github__list_issues", + "mcp__github__list_pull_requests", + "mcp__github__list_releases", + "mcp__github__list_tags", + "mcp__github__pull_request_read", + "mcp__github__search_code", + "mcp__github__search_issues", + "mcp__github__search_pull_requests", + "mcp__github__search_repositories", + "mcp__github__search_users", + "mcp__plugin_nix-tasuke_nixos" + ], + "deny": [ + "Bash(gh * delete:*)", + "Bash(gh repo archive:*)", + "Bash(gh repo rename:*)", + "Bash(rm:*)" + ] + } +} diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 0000000..bea8588 --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1 @@ +((nil . ((lsp-format-buffer-on-save . t)))) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..553eac5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,60 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# URLなどは分割困難で長過ぎることがあるためしばしば例外が許可されます。 +# あくまで指針と各種フォーマッターへの指示です。 +max_line_length = 120 + +# デフォルトのインデントをスペースで2幅にして例外の方を設定することにします。 +# これもcheckerがインデントではないものを誤認することがあるので、 +# あくまで指針と各種フォーマッターへの指示です。 +indent_style = space +indent_size = 2 + +# 標準インデントが4の言語。 +[*.{cs,csx,java,kt,kts,php,py,pyi,rs}] +indent_size = 4 + +# 標準インデントがタブの言語。 +[{*.go,*.make,*.mk,Makefile}] +indent_style = tab +indent_size = unset + +# Emacs Lispは独特なインデントルールを持つためeditorconfig側では指定しません。 +[*.el] +indent_size = unset +indent_style = unset + +[*.md] +# Markdownには最後にスペース2つ入れて強制改行するシンタックスがあるので末尾のスペースを許可。 +# 注意: なるべく強制改行は使わず、意味で段落して、改行位置はクライアントのデバイスに任せるべきです。 +trim_trailing_whitespace = false +# MarkdownはCommonMark準拠パーサーでは箇条書きリストのネストは2スペースでも動作し、 +# 番号付きリストはマーカー幅に依存するため固定値での指定は不適切です。 +# よって値を指定しません。 +indent_size = unset + +[*.{json,md,nix,yml,yaml}] +# 設定やドキュメントのファイルはURLなど改行困難なものが多いため行幅制限を無効化します。 +max_line_length = unset + +[.env{,.*}] +# 接続文字列やAPIキーなど長い設定値を含むため行幅制限を無効化します。 +max_line_length = unset + +[*.bat] +charset = latin1 +end_of_line = crlf + +[*.ps1] +charset = utf-8-bom +end_of_line = crlf + +[*.reg] +charset = utf-8-bom +end_of_line = crlf diff --git a/.elisp-autofmt b/.elisp-autofmt new file mode 100644 index 0000000..e69de29 diff --git a/.envrc b/.envrc new file mode 100755 index 0000000..2b90134 --- /dev/null +++ b/.envrc @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 開発時に使う共有の環境変数を読み込みます。 +# `dotenv_if_exists`はファイルが存在しなければ何もしません。 +# deveはtypoではなく、deve, prod, stag, testと四文字に揃えた略称です。 +dotenv_if_exists .env.deve + +# Nix Flakesの開発環境をロードします。 +use flake . --accept-flake-config + +# GitHub MCPのために各自のGitHubのTokenを取得します。 +# `GITHUB_TOKEN`にトークンを展開することは一般的に行われていることであり、 +# 危険性はほぼありません。 +# 悪意を持って環境変数にアクセスできる状況ならばどうせ基本的に`gh auth token`も起動できるはずです。 +if gh auth status &>/dev/null; then + GITHUB_TOKEN=$(gh auth token) + export GITHUB_TOKEN +else + # GitHub MCPにアクセスできなくても致命的な状況ではないので、 + # エラーログだけ出して続行します。 + log_error "GitHub CLI is not authenticated. Please run 'gh auth login' first." +fi + +# ローカル固有の環境変数を最後に読み込んで優先させます。 +dotenv_if_exists .env.local diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..643f8a2 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,52 @@ +# 出力設定 + +## 言語 + +AIは人間に話すときは日本語を使ってください。 + +しかし既存のコードのコメントなどが日本語ではない場合は、 +コメント等は既存の言語に合わせてください。 + +## 記号 + +ASCIIに対応する全角形(Fullwidth Forms)は使用禁止。 + +具体的には以下のような文字: + +- 全角括弧 `()` → 半角 `()` +- 全角コロン `:` → 半角 `:` +- 全角カンマ `,` → 半角 `,` +- 全角数字 `0-9` → 半角 `0-9` + +# 重要コマンド + +## フォーマット + +nix fmtでフォーマットとリントを実行できます。 + +```console +nix fmt +``` + +[nix-tasuke](https://github.com/ncaq/konoka/tree/master/plugins/nix-tasuke)プラグインにより、 +Claudeの応答完了時にStopフックで`nix fmt`が自動実行されます。 +ファイルの差分が出ることがあります。 + +## 統合チェック + +nix-fast-buildコマンドで統合チェックを実行できます。 + +```console +nix-fast-build --option eval-cache false --no-link --skip-cached --no-nom +``` + +# リポジトリ構成 + +Codex向けの`AGENTS.md`とClaude Code向けの`CLAUDE.md`は以下のように`.github/copilot-instructions.md`のシンボリックリンクになっています。 + +```console +AGENTS.md -> .github/copilot-instructions.md +CLAUDE.md -> .github/copilot-instructions.md +``` + +これにより各種LLM向けのドキュメントを一元管理しています。 diff --git a/.github/git-commit-instructions.md b/.github/git-commit-instructions.md new file mode 100644 index 0000000..c7c7440 --- /dev/null +++ b/.github/git-commit-instructions.md @@ -0,0 +1,148 @@ +日本語で記述してください。 + +Conventional Commitsを使います。 + +# Conventional Commits 1.0.0 + +## Summary + +The Conventional Commits specification is a lightweight convention on top of commit messages. +It provides an easy set of rules for creating an explicit commit history; +which makes it easier to write automated tools on top of. + +The commit message should be structured as follows: + +--- + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +--- + +The commit contains the following structural elements, to communicate intent to the +consumers of your library: + +1. **fix:** a commit of the _type_ `fix` patches a bug in your codebase (this correlates with [`PATCH`](http://semver.org/#summary) in Semantic Versioning). +1. **feat:** a commit of the _type_ `feat` introduces a new feature to the codebase (this correlates with [`MINOR`](http://semver.org/#summary) in Semantic Versioning). +1. **BREAKING CHANGE:** a commit that has a footer `BREAKING CHANGE:`, or appends a `!` after the type/scope, introduces a breaking API change + (correlating with [`MAJOR`](http://semver.org/#summary) in Semantic Versioning). + A BREAKING CHANGE can be part of commits of any _type_. +1. _types_ other than `fix:` and `feat:` are allowed, for example + [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) + (based on the [Angular convention](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines)) + recommends `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`, and others. +1. _footers_ other than `BREAKING CHANGE: ` may be provided and follow a convention similar to + [git trailer format](https://git-scm.com/docs/git-interpret-trailers). + +Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE). +A scope may be provided to a commit's type, to provide additional contextual information and is contained within parenthesis, e.g., `feat(parser): add ability to parse arrays`. + +## Examples + +### Commit message with description and breaking change footer + +``` +feat: allow provided config object to extend other configs + +BREAKING CHANGE: `extends` key in config file is now used for extending other config files +``` + +### Commit message with `!` to draw attention to breaking change + +``` +feat!: send an email to the customer when a product is shipped +``` + +### Commit message with scope and `!` to draw attention to breaking change + +``` +feat(api)!: send an email to the customer when a product is shipped +``` + +### Commit message with both `!` and BREAKING CHANGE footer + +``` +chore!: drop support for Node 6 + +BREAKING CHANGE: use JavaScript features not available in Node 6. +``` + +### Commit message with no body + +``` +docs: correct spelling of CHANGELOG +``` + +### Commit message with scope + +``` +feat(lang): add Polish language +``` + +### Commit message with multi-paragraph body and multiple footers + +``` +fix: prevent racing of requests + +Introduce a request id and a reference to latest request. Dismiss +incoming responses other than from latest request. + +Remove timeouts which were used to mitigate the racing issue but are +obsolete now. + +Reviewed-by: Z +Refs: #123 +``` + +## Specification + +The key words +"MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" +in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt). + +1. Commits MUST be prefixed with a type, which consists of a noun, `feat`, `fix`, etc., followed + by the OPTIONAL scope, OPTIONAL `!`, and REQUIRED terminal colon and space. +1. The type `feat` MUST be used when a commit adds a new feature to your application or library. +1. The type `fix` MUST be used when a commit represents a bug fix for your application. +1. A scope MAY be provided after a type. A scope MUST consist of a noun describing a + section of the codebase surrounded by parenthesis, e.g., `fix(parser):` +1. A description MUST immediately follow the colon and space after the type/scope prefix. + The description is a short summary of the code changes, e.g., _fix: array parsing issue when multiple spaces were contained in string_. +1. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description. +1. A commit body is free-form and MAY consist of any number of newline separated paragraphs. +1. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of + a word token, followed by either a `:` or `#` separator, followed by a string value (this is inspired by the + [git trailer convention](https://git-scm.com/docs/git-interpret-trailers)). +1. A footer's token MUST use `-` in place of whitespace characters, e.g., `Acked-by` (this helps differentiate + the footer section from a multi-paragraph body). An exception is made for `BREAKING CHANGE`, which MAY also be used as a token. +1. A footer's value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer + token/separator pair is observed. +1. Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the + footer. +1. If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., + _BREAKING CHANGE: environment variables now take precedence over config files_. +1. If included in the type/scope prefix, breaking changes MUST be indicated by a + `!` immediately before the `:`. If `!` is used, `BREAKING CHANGE:` MAY be omitted from the footer section, + and the commit description SHALL be used to describe the breaking change. +1. Types other than `feat` and `fix` MAY be used in your commit messages, e.g., _docs: update ref docs._ +1. The units of information that make up Conventional Commits MUST NOT be treated as case sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase. +1. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer. + +# type + +Must be one of the following: + +- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) +- **ci**: Changes to our CI configuration files and scripts +- **docs**: Documentation only changes +- **feat**: A new feature +- **fix**: A bug fix +- **perf**: A code change that improves performance +- **refactor**: A code change that neither fixes a bug nor adds a feature +- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) +- **test**: Adding missing tests or correcting existing tests diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..14e4b89 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,26 @@ +changelog: + exclude: + labels: + - "Type: Release" + + categories: + - title: Security Fixes + labels: ["Type: Security"] + - title: Breaking Changes + labels: ["Type: Breaking Change"] + - title: Features + labels: ["Type: Feature"] + - title: Bug Fixes + labels: ["Type: Bug"] + - title: Documentation + labels: ["Type: Documentation"] + - title: Refactoring + labels: ["Type: Refactoring"] + - title: Testing + labels: ["Type: Testing"] + - title: CI + labels: ["Type: CI"] + - title: Dependency Updates + labels: ["Type: Dependencies", "dependencies"] + - title: Other Changes + labels: ["*"] diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..910ebbc --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,61 @@ +name: check + +on: + push: + branches: [master, main] + pull_request: + merge_group: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix-fast-build: + name: nix-fast-build + runs-on: ubuntu-24.04 + permissions: + contents: read # リポジトリコンテンツの読み取り + id-token: write # niks3 OIDC認証 + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ncaq/nix-composite-action@6e28cd44693083d063bed5baf36e912c6807a151 # v1.1.1 + with: + cachix-auth-token: "${{ secrets.CACHIX_AUTH_TOKEN }}" + - run: nix run '.#nix-fast-build' -- --option eval-cache false --no-link --skip-cached --no-nom + elisp-check: + name: elisp-check + runs-on: ubuntu-24.04 + permissions: + contents: read # リポジトリコンテンツの読み取り + id-token: write # niks3 OIDC認証 + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ncaq/nix-composite-action@6e28cd44693083d063bed5baf36e912c6807a151 # v1.1.1 + with: + cachix-auth-token: "${{ secrets.CACHIX_AUTH_TOKEN }}" + - uses: purcell/setup-emacs@bdc64dc730ae1fcba200bfd52cb1b4cf6159cbe5 # v8.0 + with: + version: snapshot # 最新のバージョンで実行してEmacs正式リリースより先にエラーを検出。 + - uses: leotaku/elisp-check@cbcda75256a9195b5e6d7e6fa39390b32e08a4f3 # v1.4.1 + with: + file: auto-sudoedit*.el + warnings_as_errors: true + - uses: leotaku/elisp-check@cbcda75256a9195b5e6d7e6fa39390b32e08a4f3 # v1.4.1 + with: + file: test/*.el + check: checkdoc + warnings_as_errors: true + - uses: leotaku/elisp-check@cbcda75256a9195b5e6d7e6fa39390b32e08a4f3 # v1.4.1 + with: + file: test/*.el + check: byte-compile + warnings_as_errors: true diff --git a/.github/workflows/kyosei.yml b/.github/workflows/kyosei.yml new file mode 100644 index 0000000..b696089 --- /dev/null +++ b/.github/workflows/kyosei.yml @@ -0,0 +1,21 @@ +name: Kyosei + +on: + pull_request: + # Only opened and synchronize to avoid duplicate reviews + # on the same revision from ready_for_review or reopened events. + types: [opened, synchronize] + +permissions: {} + +jobs: + workflow: + # Reusable workflows are constrained by the caller's permissions, + # so they must be explicitly declared here. + # Claude GitHub App manages its own token, so only minimal permissions are needed. + permissions: + contents: read # Read repository contents for checkout + id-token: write # GitHub App token exchange via OIDC (needed regardless of Claude API auth method) + uses: ncaq/kyosei-action/.github/workflows/review.yml@3756e883c7efd21f4f64cbfebd5a450ecd0de511 # v1.3.0 + secrets: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2e78a4c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +name: release + +on: + # zizmor: ignore[dangerous-triggers] -- branchesで制限済み、信頼できない入力は使用していない + workflow_run: + workflows: [check] + types: [completed] + branches: [master, main] + +permissions: {} + +jobs: + release: + name: release + runs-on: ubuntu-24.04 + permissions: + contents: write # タグ作成のため + concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + if: github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: "${{ github.event.workflow_run.head_sha }}" + persist-credentials: false + fetch-depth: 0 # タグの存在確認と作成に全履歴が必要 + - name: Extract version from Emacs Lisp header + id: version + run: | + set -euo pipefail + # Emacs Lispパッケージヘッダの`;; Version:`からバージョンを抽出する。 + VERSION=$(sed -n 's/^;; Version: *//p' auto-sudoedit.el | tr -d '[:space:]') + if [ -z "$VERSION" ]; then + echo "No version found in auto-sudoedit.el" + exit 1 + elif ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then + echo "Invalid semver format: $VERSION" + exit 1 + else + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + fi + - name: Check if tag exists + id: check-tag + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if git show-ref --verify --quiet "refs/tags/v$VERSION"; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Create tag + if: steps.check-tag.outputs.skip != 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh auth setup-git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin "v$VERSION" + - name: Create GitHub Release + if: steps.check-tag.outputs.skip != 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "v$VERSION" --generate-notes diff --git a/.gitignore b/.gitignore index 2408d62..654ff59 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,16 @@ +# Nix + +# Ignore build outputs from performing a nix-build or `nix build` command +result +result-* + +# Ignore automatically generated direnv output +.direnv + +# Ignore local override env +.env.local + +# Emacs Lisp + *-autoloads.el *.elc diff --git a/.marksman.toml b/.marksman.toml new file mode 100644 index 0000000..371de3d --- /dev/null +++ b/.marksman.toml @@ -0,0 +1,2 @@ +[core] +title_from_heading = false diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c58b142 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "deepwiki": { + "type": "http", + "url": "https://mcp.deepwiki.com/mcp" + }, + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer ${GITHUB_TOKEN}" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..02dd134 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..02dd134 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/README.md b/README.md index 3c5fcb7..c311f44 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,31 @@ Place it in melpa or directly downloaded by PATH. If downloaded directly -~~~ el +```el (require 'auto-sudoedit) -~~~ +``` auto-sudoedit works when minor mode is enabled. -~~~ el +```el (auto-sudoedit-mode 1) -~~~ +``` If you enable minor mode, When you open a file that requires root privileges, It will be reopened automatically. +## Recommend + +If you find the following message depressing + +``` +Autosave file on local temporary directory, do you want to continue? (y or n) y +``` + +On a non-shared PC, you can allow some security risk and use. +Set `tramp-allow-unsafe-temporary-files` to `t`. + # Japanese ## 概要 @@ -53,16 +64,28 @@ melpaか直接ダウンロードでPATHが通った場所に置きます。 直接ダウンロードした場合は、 -~~~el +```el (require 'auto-sudoedit) -~~~ +``` auto-sudoeditはマイナーモードが有効の時に動きます。 -~~~el +```el (auto-sudoedit-mode 1) -~~~ +``` マイナーモードを有効にしておけば、 root権限が必要なファイルを開いたときに、 自動で開き直されます。 + +## 推奨 + +もし以下のメッセージが鬱陶しいならば、 + +``` +Autosave file on local temporary directory, do you want to continue? (y or n) y +``` + +共有してないPCでは多少のセキュリティリスクを許容して、 +`tramp-allow-unsafe-temporary-files` +を`t`にするのをオススメします。 diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..737ca6b --- /dev/null +++ b/_typos.toml @@ -0,0 +1,9 @@ +[default.extend-words] +# Part of "RGBa" (Pillow's pre-multiplied alpha RGB mode) +Ba = "Ba" +# HSA is something AMD uses for their GPUs +HSA = "HSA" +# 社名。分割で認識されなくなります。 +Hashi = "Hashi" +# Immediately Invoked Function Expression (IIFE)の略語。camelCase分割で"IIF"をtypoと判定する。 +IIF = "IIF" diff --git a/auto-sudoedit.el b/auto-sudoedit.el index 81daa70..af8f5e2 100644 --- a/auto-sudoedit.el +++ b/auto-sudoedit.el @@ -1,7 +1,7 @@ ;;; auto-sudoedit.el --- Auto sudo edit by tramp -*- lexical-binding: t -*- ;; Author: ncaq -;; Version: 1.1.0 +;; Version: 1.1.1 ;; Package-Requires: ((emacs "26.1")(f "0.19.0")) ;; URL: https://github.com/ncaq/auto-sudoedit @@ -12,33 +12,53 @@ ;;; Code: +(require 'dired) (require 'f) +(require 'recentf) (require 'tramp) +(require 'tramp-sh) + +(defcustom auto-sudoedit-ask nil + "Ask for user confirmation when reopening?" + :group 'auto-sudoedit + :type 'boolean) (defun auto-sudoedit-path (curr-path) "To convert path to tramp using sudo path. Argument CURR-PATH is current path. -The result is string or nil. -The nil when will be not able to connect by sudo." +The result is a cons cell in the format \\='(USER . TRAMP-PATH). +USER is nil, when we cannot open via sudo." ;; trampのpathに変換します - (let ((tramp-path - (if (tramp-tramp-file-p curr-path) - (auto-sudoedit-path-from-tramp-ssh-like curr-path) - (concat "/sudo::" curr-path)))) + (let* ((file-owner (auto-sudoedit-file-owner curr-path)) + (tramp-path + (if (tramp-tramp-file-p curr-path) + (auto-sudoedit-path-from-tramp-ssh-like curr-path file-owner) + (concat "/sudo::" curr-path)))) (if (and - ;; Current path may not exist; back up to the first existing parent - ;; and see if it's writable - (let ((first-existing-path (f-traverse-upwards #'f-exists? curr-path))) - (not (and first-existing-path (f-writable? first-existing-path)))) + ;; We must know the file owner's login name + ;; If we can't, we don't know which user to sudo as + file-owner + ;; The file owner must be different from our current user so that the sudo makes sense + (not (string= file-owner (auto-sudoedit-current-user curr-path))) ;; 変換前のパスと同じでなく(2回めの変換はしない) - (not (equal curr-path tramp-path)) - ;; sudoで開ける場合は変換したものを返します - (f-writable? tramp-path)) - tramp-path - nil))) - -(defun auto-sudoedit-path-from-tramp-ssh-like (curr-path) - "Argument CURR-PATH is tramp path(that use protocols such as ssh)." + (not (equal curr-path tramp-path))) + (cons file-owner tramp-path) + (cons nil curr-path)))) + +(defun auto-sudoedit-file-owner (path) + "Determine the login name of the user PATH belongs to." + (file-attribute-user-id (file-attributes path 'string))) + +(defun auto-sudoedit-current-user (path) + "Determine the user name visiting PATH. E.g. local Emacs user or ssh login." + (if (tramp-tramp-file-p path) + ;; We can't just go by the user in the tramp filename, because it may have been omitted + (tramp-get-remote-uid (tramp-dissect-file-name path) 'string) + (user-login-name))) + +(defun auto-sudoedit-path-from-tramp-ssh-like (curr-path new-user) + "Argument CURR-PATH is tramp path(that use protocols such as ssh). +NEW-USER is the user for sudo." (let* ((file-name (tramp-dissect-file-name curr-path)) (method (tramp-file-name-method file-name)) (user (tramp-file-name-user file-name)) @@ -47,11 +67,19 @@ The nil when will be not able to connect by sudo." (localname (tramp-file-name-localname file-name)) (hop (tramp-file-name-hop file-name)) (new-method "sudo") - (new-user "root") (new-host host) (new-port port) (new-localname localname) - (new-hop (format "%s%s%s:%s%s|" (or hop "") method (if user (concat user "@") "") host (if port (concat port "#") "")))) + (new-hop + (format "%s%s:%s%s%s|" + (or hop "") method + (if user + (concat user "@") + "") + host + (if port + (concat "#" port) + "")))) ;; 最終メソッドがsudoである場合それ以上の変換は無意味なので行わない。 (if (equal method "sudo") curr-path @@ -71,31 +99,61 @@ The nil when will be not able to connect by sudo." (defun auto-sudoedit-sudoedit (curr-path) "Open sudoedit. Argument CURR-PATH is path." (interactive (list (auto-sudoedit-current-path))) - (find-file (auto-sudoedit-path curr-path))) - -(defun auto-sudoedit (orig-func &rest args) - "`auto-sudoedit' around-advice. -Argument ORIG-FUNC is original function. -Argument ARGS is original function arguments." - (let* ((curr-path (car args)) - (tramp-path (auto-sudoedit-path curr-path))) - (if tramp-path - (apply orig-func tramp-path (cdr args)) - (apply orig-func args)))) + (find-file (cdr (auto-sudoedit-path curr-path)))) + +(defun auto-sudoedit () + "`auto-sudoedit' hook for `find-file'. +Reopen the buffer via tramp with sudo method." + (let* ((curr-path (auto-sudoedit-current-path)) + (remote-info (auto-sudoedit-path curr-path)) + (user (car remote-info)) + (tramp-path (cdr remote-info))) + (when (and curr-path + user tramp-path (not (and (tramp-tramp-file-p curr-path) (tramp-sh-handle-file-writable-p curr-path))) + (or (not auto-sudoedit-ask) + (y-or-n-p (format "This buffer belongs to user %s. Reopen this buffer as user %s? " user user)))) + ;; We have to tell emacs that this buffer now visits another file (actually the same one, just via tramp sudo) + ;; We have to do things differently for normal files and for dired + (when buffer-file-name + (set-visited-file-name tramp-path t)) + (when dired-directory + ;; Remove the buffer as displaying the old directory path in dired's active buffer list + (dired-unadvertise dired-directory) + (setq list-buffers-directory tramp-path) + (setq dired-directory tramp-path) + (setq default-directory tramp-path) + ;; Insert the new directory path in dired's active buffer list + (dired-advertise)) + ;; Remove the old filename from the recentf-list + ;; TODO: Is this a good idea? Could this break something? + (when (string= (car recentf-list) curr-path) + (pop recentf-list)) + ;; We have changed the way emacs edits the file + ;; Therefore we have to reinitialize the buffer (read-only, etc.) + ;; Also the file may have not been readable before + ;; Revert buffer fixes this for us. + ;; Use the arguments to prevent user confirmation + ;; (There are no changes that could be discarded in the buffer anyways, it was just opened) + (revert-buffer t t)))) ;;;###autoload -(define-minor-mode - auto-sudoedit-mode - "automatic do sudo by tramp when need root file" +(define-minor-mode auto-sudoedit-mode + "When sudo is required, it automatically reopens in tramp." + :group 'auto-sudoedit + :global t :init-value 0 - :lighter " ASE" + :lighter + " ASE" (if auto-sudoedit-mode (progn - (advice-add 'find-file :around 'auto-sudoedit) - (advice-add 'dired :around 'auto-sudoedit)) - (advice-remove 'find-file 'auto-sudoedit) - (advice-remove 'dired 'auto-sudoedit))) + (add-hook 'find-file-hook #'auto-sudoedit) + (add-hook 'dired-mode-hook #'auto-sudoedit)) + (remove-hook 'find-file-hook #'auto-sudoedit) + (remove-hook 'dired-mode-hook #'auto-sudoedit))) (provide 'auto-sudoedit) +;; Local Variables: +;; fill-column: 120 +;; End: ;;; auto-sudoedit.el ends here diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..06dfd1f --- /dev/null +++ b/flake.lock @@ -0,0 +1,123 @@ +{ + "nodes": { + "emacs-elisp-autofmt": { + "flake": false, + "locked": { + "lastModified": 1775532385, + "narHash": "sha256-w9vq+BJrV+A2RFY3VrYHgvfFkEH62NGpvllYqsDI/ro=", + "ref": "refs/heads/main", + "rev": "b1cdd8661930a35b1633ccc28b27b793145cd108", + "revCount": 365, + "type": "git", + "url": "https://codeberg.org/ideasman42/emacs-elisp-autofmt.git" + }, + "original": { + "type": "git", + "url": "https://codeberg.org/ideasman42/emacs-elisp-autofmt.git" + } + }, + "emacs-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1776591781, + "narHash": "sha256-N3P2tFDyAjjCIZr6+7Yl6MN2oqRRsjgbzyQOIfNfFMs=", + "owner": "nix-community", + "repo": "emacs-overlay", + "rev": "0ab23f059a8a22b421a5298aca8b54ddb233935d", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "emacs-overlay", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1775087534, + "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776434932, + "narHash": "sha256-gyqXNMgk3sh+ogY5svd2eNLJ6oEwzbAeaoBrrxD0lKk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c7f47036d3df2add644c46d712d14262b7d86c0c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1774748309, + "narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "333c4e0545a6da976206c74db8773a1645b5870a", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "emacs-elisp-autofmt": "emacs-elisp-autofmt", + "emacs-overlay": "emacs-overlay", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775636079, + "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1b9af3d --- /dev/null +++ b/flake.nix @@ -0,0 +1,153 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-parts.url = "github:hercules-ci/flake-parts"; + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + emacs-overlay = { + url = "github:nix-community/emacs-overlay"; + inputs = { + nixpkgs.follows = "nixpkgs"; + nixpkgs-stable.follows = "nixpkgs"; + }; + }; + emacs-elisp-autofmt = { + url = "git+https://codeberg.org/ideasman42/emacs-elisp-autofmt.git"; + flake = false; + }; + }; + + outputs = + inputs@{ + nixpkgs, + flake-parts, + treefmt-nix, + emacs-overlay, + ... + }: + flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ + treefmt-nix.flakeModule + ]; + + systems = [ + "aarch64-linux" + "x86_64-linux" + ]; + + perSystem = + { + pkgs, + system, + ... + }: + let + emacs-overlay-pkgs = import nixpkgs { + inherit system; + overlays = [ + emacs-overlay.overlays.default + ]; + }; + emacsForIt = emacs-overlay-pkgs.emacsWithPackagesFromPackageRequires { + packageElisp = builtins.readFile ./auto-sudoedit.el; + }; + in + { + treefmt.config = { + projectRootFile = "flake.nix"; + programs = { + actionlint.enable = true; + deadnix.enable = true; + nixfmt.enable = true; + prettier.enable = true; + shellcheck.enable = true; + shfmt.enable = true; + statix.enable = true; + typos.enable = true; + zizmor.enable = true; + }; + settings.formatter = { + editorconfig-checker = { + command = pkgs.editorconfig-checker; + includes = [ "*" ]; + }; + elisp-autofmt = { + command = pkgs.writeShellApplication { + name = "elisp-autofmt"; + runtimeInputs = with pkgs; [ + emacs + python3 + ]; + text = '' + python3 ${inputs.emacs-elisp-autofmt}/elisp-autofmt-cmd.py "$@" + ''; + }; + includes = [ "*.el" ]; + }; + zizmor.options = [ "--pedantic" ]; + }; + }; + + checks = { + inherit emacsForIt; + ert = pkgs.runCommand "auto-sudoedit-ert-test" { } '' + ${emacsForIt}/bin/emacs -Q --batch \ + -L ${./.} \ + -l ${./test/auto-sudoedit-test.el} \ + -f ert-run-tests-batch-and-exit + touch $out + ''; + }; + + packages = { + # flake.lockの管理バージョンをre-exportすることで安定した利用を促進。 + inherit (pkgs) nix-fast-build; + inherit emacsForIt; + }; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + # treefmtで指定したプログラムの単体版。 + actionlint + deadnix + editorconfig-checker + emacsPackages.elisp-autofmt + nixfmt + prettier + shellcheck + shfmt + statix + typos + zizmor + + # nixの関連ツール。 + nil + nix-fast-build + + # GitHub関連ツール。 + gh + + # Emacs関連ツール。 + emacsForIt + ]; + }; + }; + }; + + nixConfig = { + extra-substituters = [ + "https://cache.nixos.org/" + "https://niks3-public.ncaq.net/" + "https://ncaq.cachix.org/" + "https://nix-community.cachix.org/" + ]; + extra-trusted-public-keys = [ + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + "niks3-public.ncaq.net-1:e/B9GomqDchMBmx3IW/TMQDF8sjUCQzEofKhpehXl04=" + "ncaq.cachix.org-1:XF346GXI2n77SB5Yzqwhdfo7r0nFcZBaHsiiMOEljiE=" + "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" + ]; + }; +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..71cc81d --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>ncaq/renovate-config"] +} diff --git a/statix.toml b/statix.toml new file mode 100644 index 0000000..061d8e7 --- /dev/null +++ b/statix.toml @@ -0,0 +1,3 @@ +disabled = [ + "eta_reduction", # nixfmtによるフォーマットと衝突するため無効化。 +] diff --git a/test/auto-sudoedit-test.el b/test/auto-sudoedit-test.el new file mode 100644 index 0000000..c22b8e6 --- /dev/null +++ b/test/auto-sudoedit-test.el @@ -0,0 +1,360 @@ +;;; auto-sudoedit-test.el --- Tests for auto-sudoedit -*- lexical-binding: t -*- + +;;; Commentary: + +;; ERT tests for auto-sudoedit. + +;;; Code: + +(require 'ert) +(require 'auto-sudoedit) + +(ert-deftest auto-sudoedit-path/local-other-user () + "Local path owned by another user should be converted to sudo path." + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq"))) + (let ((result (auto-sudoedit-path "/etc/hosts"))) + (should (equal (car result) "root")) + (should (equal (cdr result) "/sudo::/etc/hosts"))))) + +(ert-deftest auto-sudoedit-path/local-same-user () + "Local path owned by current user should not be converted." + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "ncaq")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq"))) + (let ((result (auto-sudoedit-path "/home/ncaq/file.txt"))) + (should (null (car result))) + (should (equal (cdr result) "/home/ncaq/file.txt"))))) + +(ert-deftest auto-sudoedit-path/owner-unknown () + "When file owner cannot be determined, should not convert." + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) nil)) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq"))) + (let ((result (auto-sudoedit-path "/some/path"))) + (should (null (car result))) + (should (equal (cdr result) "/some/path"))))) + +(ert-deftest auto-sudoedit-path/already-sudo () + "Already a sudo tramp path should not be converted again." + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq"))) + (let* ((sudo-path "/sudo::/etc/hosts") + (result (auto-sudoedit-path sudo-path))) + ;; auto-sudoedit-path-from-tramp-ssh-like returns curr-path when method is sudo, + ;; so tramp-path equals curr-path, and the "not equal" check fails. + (should (null (car result))) + (should (equal (cdr result) sudo-path))))) + +(ert-deftest auto-sudoedit-path/ssh-other-user () + "SSH tramp path owned by another user should be converted to sudo." + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq"))) + (let ((result (auto-sudoedit-path "/ssh:host:/etc/hosts"))) + (should (equal (car result) "root")) + ;; The result should use sudo method for the target path. + (should (string-match-p "sudo:" (cdr result))) + (should (string-match-p "root@" (cdr result))) + (should (string-match-p "/etc/hosts\\'" (cdr result)))))) + +(ert-deftest auto-sudoedit-path/ssh-same-user () + "SSH tramp path owned by current user should not be converted." + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "ncaq")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq"))) + (let ((result (auto-sudoedit-path "/ssh:host:/home/ncaq/file"))) + (should (null (car result))) + (should (equal (cdr result) "/ssh:host:/home/ncaq/file"))))) + +(ert-deftest auto-sudoedit-path-from-tramp-ssh-like/basic-ssh () + "Basic SSH path should be converted to sudo." + (let ((result (auto-sudoedit-path-from-tramp-ssh-like "/ssh:example.com:/etc/hosts" "root"))) + (should (string-match-p "sudo:" result)) + (should (string-match-p "root@" result)) + (should (string-match-p "example\\.com" result)) + (should (string-match-p "/etc/hosts\\'" result)))) + +(ert-deftest auto-sudoedit-path-from-tramp-ssh-like/ssh-with-user () + "SSH path with explicit user should be converted to sudo." + (let ((result (auto-sudoedit-path-from-tramp-ssh-like "/ssh:admin@example.com:/etc/hosts" "root"))) + (should (string-match-p "sudo:" result)) + (should (string-match-p "root@" result)) + (should (string-match-p "example\\.com" result)) + (should (string-match-p "/etc/hosts\\'" result)))) + +(ert-deftest auto-sudoedit-path-from-tramp-ssh-like/ssh-with-port () + "SSH path with port should be converted to sudo preserving the port." + (let ((result (auto-sudoedit-path-from-tramp-ssh-like "/ssh:example.com#2222:/etc/hosts" "root"))) + (should (string-match-p "sudo:" result)) + (should (string-match-p "root@" result)) + (should (string-match-p "example\\.com" result)) + (should (string-match-p "#2222" result)) + (should (string-match-p "/etc/hosts\\'" result)))) + +(ert-deftest auto-sudoedit-path-from-tramp-ssh-like/already-sudo () + "Path with sudo method should be returned as-is." + (let* ((sudo-path "/sudo:root@localhost:/etc/hosts") + (result (auto-sudoedit-path-from-tramp-ssh-like sudo-path "root"))) + (should (equal result sudo-path)))) + +(ert-deftest auto-sudoedit-path-from-tramp-ssh-like/scp-method () + "SCP method should also be converted to sudo." + (let ((result (auto-sudoedit-path-from-tramp-ssh-like "/scp:example.com:/etc/hosts" "root"))) + (should (string-match-p "sudo:" result)) + (should (string-match-p "root@" result)) + (should (string-match-p "example\\.com" result)) + (should (string-match-p "/etc/hosts\\'" result)))) + +(ert-deftest auto-sudoedit-path-from-tramp-ssh-like/multi-hop () + "Multi-hop path should be converted to sudo with target host." + (let ((result (auto-sudoedit-path-from-tramp-ssh-like "/ssh:jump|ssh:target:/etc/hosts" "root"))) + (should (string-match-p "sudo:" result)) + (should (string-match-p "root@" result)) + (should (string-match-p "target" result)) + (should (string-match-p "/etc/hosts\\'" result)))) + +(ert-deftest auto-sudoedit-current-path/file-buffer () + "In a file buffer, should return variable `buffer-file-name'." + (with-temp-buffer + (setq buffer-file-name "/tmp/test-file.el") + (should (equal (auto-sudoedit-current-path) "/tmp/test-file.el")))) + +(ert-deftest auto-sudoedit-current-path/dired-buffer () + "In a dired-like buffer, should return `list-buffers-directory'." + (with-temp-buffer + (setq buffer-file-name nil) + (setq list-buffers-directory "/tmp/test-dir/") + (should (equal (auto-sudoedit-current-path) "/tmp/test-dir/")))) + +(ert-deftest auto-sudoedit-current-path/no-path () + "In a buffer with no file and no directory, should return nil." + (with-temp-buffer + (setq buffer-file-name nil) + (setq list-buffers-directory nil) + (should (null (auto-sudoedit-current-path))))) + +(ert-deftest auto-sudoedit-current-user/local-path () + "For a local path, should return function `user-login-name'." + (should (equal (auto-sudoedit-current-user "/etc/hosts") (user-login-name)))) + +(ert-deftest auto-sudoedit-current-user/tramp-path () + "For a tramp path, should return the remote user via function `tramp-get-remote-uid'." + (cl-letf (((symbol-function 'tramp-get-remote-uid) + (lambda (_ id-format) + (when (eq id-format 'string) + "remoteuser")))) + (should (equal (auto-sudoedit-current-user "/ssh:host:/etc/hosts") "remoteuser")))) + +(ert-deftest auto-sudoedit-mode/enable-adds-hooks () + "Enabling the mode should add hooks." + (unwind-protect + (progn + (auto-sudoedit-mode 1) + (should (memq #'auto-sudoedit find-file-hook)) + (should (memq #'auto-sudoedit dired-mode-hook))) + (auto-sudoedit-mode -1))) + +(ert-deftest auto-sudoedit-mode/disable-removes-hooks () + "Disabling the mode should remove hooks." + (auto-sudoedit-mode 1) + (auto-sudoedit-mode -1) + (should-not (memq #'auto-sudoedit find-file-hook)) + (should-not (memq #'auto-sudoedit dired-mode-hook))) + +(ert-deftest auto-sudoedit-file-owner/existing-file () + "For an existing file, should return a string (the owner name)." + ;; /etc/hosts should exist on any Unix-like system. + (let ((owner (auto-sudoedit-file-owner "/etc/hosts"))) + (should (stringp owner)))) + +(ert-deftest auto-sudoedit-file-owner/nonexistent-file () + "For a nonexistent file, should return nil." + (should (null (auto-sudoedit-file-owner "/nonexistent/path/file")))) + +(ert-deftest auto-sudoedit-current-path/file-takes-priority () + "When both variable `buffer-file-name' and variable `list-buffers-directory' are set, file takes priority." + (with-temp-buffer + (setq buffer-file-name "/tmp/file.el") + (setq list-buffers-directory "/tmp/dir/") + (should (equal (auto-sudoedit-current-path) "/tmp/file.el")))) + +(ert-deftest auto-sudoedit-sudoedit/calls-find-file-with-sudo-path () + "Function `auto-sudoedit-sudoedit' should call function `find-file' with the converted sudo path." + (let (find-file-called-with) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'find-file) (lambda (path) (setq find-file-called-with path)))) + (auto-sudoedit-sudoedit "/etc/hosts") + (should (equal find-file-called-with "/sudo::/etc/hosts"))))) + +(ert-deftest auto-sudoedit-sudoedit/same-user-opens-original () + "Function `auto-sudoedit-sudoedit' with same user should open the original path." + (let (find-file-called-with) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "ncaq")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'find-file) (lambda (path) (setq find-file-called-with path)))) + (auto-sudoedit-sudoedit "/home/ncaq/file.txt") + (should (equal find-file-called-with "/home/ncaq/file.txt"))))) + +(ert-deftest auto-sudoedit/file-buffer-not-writable () + "Hook should reopen file via sudo when owned by a different user." + (with-temp-buffer + (setq buffer-file-name "/etc/hosts") + (let ((auto-sudoedit-ask nil) + (recentf-list (list "/etc/hosts")) + revert-buffer-args) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'tramp-tramp-file-p) (lambda (_) nil)) + ((symbol-function 'set-visited-file-name) (lambda (path &optional _) (setq buffer-file-name path))) + ((symbol-function 'revert-buffer) (lambda (&rest args) (setq revert-buffer-args args)))) + (auto-sudoedit) + (should (equal buffer-file-name "/sudo::/etc/hosts")) + ;; The old path should be removed from recentf-list. + (should (null recentf-list)) + ;; revert-buffer should be called with (t t) to skip confirmation. + (should (equal revert-buffer-args '(t t))))))) + +(ert-deftest auto-sudoedit/same-user-no-change () + "Hook should not change anything when file is owned by current user." + (with-temp-buffer + (setq buffer-file-name "/home/ncaq/file.txt") + (let ((auto-sudoedit-ask nil) + (original-name buffer-file-name)) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "ncaq")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq"))) + (auto-sudoedit) + (should (equal buffer-file-name original-name)))))) + +(ert-deftest auto-sudoedit/nil-path-no-error () + "Hook should not error when current path is nil." + (with-temp-buffer + (setq buffer-file-name nil) + (setq list-buffers-directory nil) + (should-not (auto-sudoedit)))) + +(ert-deftest auto-sudoedit/ask-confirmed () + "Hook should proceed when variable `auto-sudoedit-ask' is t and user confirms." + (with-temp-buffer + (setq buffer-file-name "/etc/hosts") + (let ((auto-sudoedit-ask t) + (recentf-list (list "/etc/hosts")) + revert-buffer-called) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'tramp-tramp-file-p) (lambda (_) nil)) + ((symbol-function 'y-or-n-p) (lambda (_) t)) + ((symbol-function 'set-visited-file-name) (lambda (path &optional _) (setq buffer-file-name path))) + ((symbol-function 'revert-buffer) (lambda (&rest _) (setq revert-buffer-called t)))) + (auto-sudoedit) + (should (equal buffer-file-name "/sudo::/etc/hosts")) + (should revert-buffer-called))))) + +(ert-deftest auto-sudoedit/ask-denied () + "Hook should not proceed when variable `auto-sudoedit-ask' is t and user denies." + (with-temp-buffer + (setq buffer-file-name "/etc/hosts") + (let ((auto-sudoedit-ask t) + (original-name buffer-file-name)) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'tramp-tramp-file-p) (lambda (_) nil)) + ((symbol-function 'y-or-n-p) (lambda (_) nil))) + (auto-sudoedit) + (should (equal buffer-file-name original-name)))))) + +(ert-deftest auto-sudoedit/recentf-not-popped-when-different () + "Hook should not pop recentf-list when the top entry differs from current path." + (with-temp-buffer + (setq buffer-file-name "/etc/hosts") + (let ((auto-sudoedit-ask nil) + (recentf-list (list "/some/other/file")) + revert-buffer-called) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'tramp-tramp-file-p) (lambda (_) nil)) + ((symbol-function 'set-visited-file-name) (lambda (path &optional _) (setq buffer-file-name path))) + ((symbol-function 'revert-buffer) (lambda (&rest _) (setq revert-buffer-called t)))) + (auto-sudoedit) + (should (equal recentf-list (list "/some/other/file"))) + (should revert-buffer-called))))) + +(ert-deftest auto-sudoedit/tramp-writable-no-change () + "Hook should not reopen when tramp file is already writable." + (with-temp-buffer + (setq buffer-file-name "/ssh:host:/etc/hosts") + (let ((auto-sudoedit-ask nil) + (original-name buffer-file-name)) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'tramp-tramp-file-p) (lambda (_) t)) + ((symbol-function 'tramp-sh-handle-file-writable-p) (lambda (_) t))) + (auto-sudoedit) + (should (equal buffer-file-name original-name)))))) + +(ert-deftest auto-sudoedit/tramp-not-writable-reopens () + "Hook should reopen via sudo when tramp file is not writable." + (with-temp-buffer + (setq buffer-file-name "/ssh:host:/etc/hosts") + (let ((auto-sudoedit-ask nil) + (recentf-list nil) + revert-buffer-called) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'tramp-tramp-file-p) (lambda (_) t)) + ((symbol-function 'tramp-sh-handle-file-writable-p) (lambda (_) nil)) + ((symbol-function 'set-visited-file-name) (lambda (path &optional _) (setq buffer-file-name path))) + ((symbol-function 'revert-buffer) (lambda (&rest _) (setq revert-buffer-called t)))) + (auto-sudoedit) + (should (string-match-p "sudo:" buffer-file-name)) + (should (string-match-p "/etc/hosts\\'" buffer-file-name)) + (should revert-buffer-called))))) + +(ert-deftest auto-sudoedit/dired-buffer-not-writable () + "Hook should update variable `dired-directory' when directory is owned by another user." + (with-temp-buffer + (setq buffer-file-name nil) + (setq dired-directory "/root/") + (setq list-buffers-directory "/root/") + (setq default-directory "/root/") + (let ((auto-sudoedit-ask nil) + (recentf-list nil) + revert-buffer-args + unadvertise-called-with + advertise-called) + (cl-letf (((symbol-function 'auto-sudoedit-file-owner) (lambda (_) "root")) + ((symbol-function 'auto-sudoedit-current-user) (lambda (_) "ncaq")) + ((symbol-function 'tramp-tramp-file-p) (lambda (_) nil)) + ((symbol-function 'dired-unadvertise) (lambda (dir) (setq unadvertise-called-with dir))) + ((symbol-function 'dired-advertise) (lambda () (setq advertise-called t))) + ((symbol-function 'revert-buffer) (lambda (&rest args) (setq revert-buffer-args args)))) + (auto-sudoedit) + (should (equal dired-directory "/sudo::/root/")) + (should (equal list-buffers-directory "/sudo::/root/")) + (should (equal default-directory "/sudo::/root/")) + (should (equal unadvertise-called-with "/root/")) + (should advertise-called) + (should (equal revert-buffer-args '(t t))))))) + +(ert-deftest auto-sudoedit-mode/toggle () + "Enabling then disabling the mode should restore original hook state." + (auto-sudoedit-mode -1) + (let ((original-find-file-hook (copy-sequence find-file-hook)) + (original-dired-mode-hook (copy-sequence dired-mode-hook))) + (unwind-protect + (progn + (auto-sudoedit-mode 1) + (auto-sudoedit-mode -1) + (should (equal find-file-hook original-find-file-hook)) + (should (equal dired-mode-hook original-dired-mode-hook))) + (auto-sudoedit-mode -1)))) + +(ert-deftest auto-sudoedit-mode/lighter () + "Mode lighter should be \" ASE\"." + (unwind-protect + (progn + (auto-sudoedit-mode 1) + (should (member '(auto-sudoedit-mode " ASE") minor-mode-alist))) + (auto-sudoedit-mode -1))) + +;; Local Variables: +;; fill-column: 120 +;; End: +;;; auto-sudoedit-test.el ends here