From 73b650dc9d3851f0cbe5721598befbe4bb181326 Mon Sep 17 00:00:00 2001 From: owa <20108042+owayo@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:54:33 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20install=20=E5=BE=8C=E3=82=82=E6=97=A2?= =?UTF-8?q?=E5=AD=98=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E6=94=B9?= =?UTF-8?q?=E8=A1=8C=E5=BD=A2=E5=BC=8F=E3=82=92=E4=BF=9D=E6=8C=81=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部コマンドが書いた改行をそのまま採用していたため、Windows では LF のファイルが CRLF になり、更新箇所以外まで差分に出ることがあった。 コマンドの管理対象と Cargo age 監査の受領書を検証した後に、既存の テキストファイルを元の LF または CRLF へ戻す。更新した内容、末尾改行、 権限を保ち、書き戻したバイト列を復元用の追跡状態へ反映する。 新規ファイル、混在改行、バイナリと mise の sidecar は変換しない。 改行を書き戻せない場合は成功扱いにせず、既存の復元処理へ進める。 失敗時も採用済みの生バイト列を追跡し、外部編集の検知を維持する。 Cargo と mise の再書き込み、失敗時の復元、外部編集の保護を検証する 回帰テストを追加し、使い方の英語版と日本語版に保持範囲を記載した。 --- docs/usage.ja.md | 2 + docs/usage.md | 2 + src/install/transaction.rs | 12 +- src/install/transaction/line_endings.rs | 180 +++++++++++++++++++++++ src/install/transaction/tests.rs | 186 ++++++++++++++++++++++++ 5 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/install/transaction/line_endings.rs diff --git a/docs/usage.ja.md b/docs/usage.ja.md index 42c0ef5..9f8429e 100644 --- a/docs/usage.ja.md +++ b/docs/usage.ja.md @@ -357,6 +357,8 @@ Cargo は `cargo update` のあと、既存の age 差し戻しと最終検証 - Node は pnpm workspace の共有ルート、またはマニフェストのあるディレクトリで install を実行します。`--only` があると、Rust は共有 Cargo.lock のディレクトリ(lock の新規生成時は workspace ルート)へまとめ、PHP は更新した composer.json のディレクトリで実行します。それ以外は [`.depup`](configuration.ja.md#depup-設定ファイル) がなければ対象ディレクトリ(PATH 引数)を使い、あれば更新したマニフェストを含む対象のうち最も深いディレクトリを使います。 - install は 1 つずつ、ディレクトリのパス順に実行します。同じディレクトリでは、言語ごとに 1 回だけです。パッケージマネージャーの出力は画面に流さずに depup が受け取り、標準エラー出力は install が失敗したときだけ表示します。 +既存のテキスト形式のマニフェストと lock は、Windows でも元の LF または CRLF をファイルごとに保持します。パッケージマネージャーの実行後と、追跡対象の Cargo age 調整後に元の改行形式へ戻します。解決したバージョンや、書き込み後の末尾改行の有無は変えません。新規ファイルはパッケージマネージャーの改行形式を使います。元から改行が混在するファイル、改行のないファイル、バイナリ、mise の sidecar メタデータは改行を変換しません。 + mise では、`mise install` が成功しても既存の platform 情報が欠けていれば、対象のツールの lock を再生成します。元の platform 集合と lock の形式を保てない場合は、install 失敗と同じ復元処理を行います。詳しくは「[lock の platform 情報](ecosystems.ja.md#lock-の-platform-情報)」を参照してください。 install が失敗すると、そのプロジェクトのマニフェストと既知のパッケージマネージャー管理ファイルを復元します。最後に `Error: Some package manager installs failed` を出し、終了コード 1 で終わります。起動不能、ポリシーや実行範囲の準備エラー、保存用コピーの取得失敗、Gradle Wrapper の更新失敗も対象です。Cargo.lock、pnpm workspace、Tauri プロジェクト、Gradle Wrapper を共有する処理は、まとめて成功か復元にします。失敗した単位の残りの処理は実行しません。独立したプロジェクトの処理は続け、成功した変更を残します。 diff --git a/docs/usage.md b/docs/usage.md index a24efe1..0cb7ff8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -356,6 +356,8 @@ With `--install`, depup runs each project's package manager after writing the ma - Node installs run at the shared pnpm workspace root or the manifest directory. With `--only`, Rust updates are grouped at their shared Cargo.lock directory (or workspace root for a new lock), and PHP installs run beside each updated composer.json. Other installs use the target directory (PATH) without [`.depup`](configuration.md#depup-configuration-file), or the deepest listed directory containing the updated manifest when `.depup` is present. - Installs run one at a time, in directory path order, and each language runs at most once per directory. The package manager's output is captured instead of streamed; its stderr is printed only when the install fails. +Existing text manifests and lock files keep their uniform LF or CRLF line endings, including on Windows. depup restores each file's original convention after a package-manager command or a tracked Cargo age adjustment, without changing the resulting versions or adding or removing a final newline. New files use the manager's convention. Files with mixed line endings or no line breaks, binary files, and mise sidecar metadata are left as written by the manager. + For mise, a successful `mise install` is followed by a targeted lock refresh only when existing platform metadata was lost. The refresh preserves the original platform coverage and lock format; a failure or incomplete result follows the same recovery path as an install failure. See [mise lockfile platforms](ecosystems.md#lockfile-platforms). If an install fails, depup restores that project's manifests and known package-manager files, then exits with code 1 (`Error: Some package manager installs failed`). This also applies to command startup, policy, scope preparation, snapshot, and Gradle Wrapper failures. Jobs sharing a Cargo lock, a pnpm workspace, a Tauri project, or a Gradle Wrapper succeed or recover together; remaining jobs in a failed unit are skipped. Independent projects continue and keep their successful changes. diff --git a/src/install/transaction.rs b/src/install/transaction.rs index 450ca8a..3077cce 100644 --- a/src/install/transaction.rs +++ b/src/install/transaction.rs @@ -718,6 +718,8 @@ impl InstallTransactions { return Err("lockfile changed after the tracked audit".into()); } record.expected = expected; + line_endings::preserve(record) + .map_err(|error| format!("Cannot preserve lockfile line endings: {error}"))?; Ok(()) } @@ -878,7 +880,14 @@ impl InstallTransactions { continue; } match read_record(record) { - Ok(state) if owned.contains(key) => record.expected = state, + Ok(state) if owned.contains(key) => { + record.expected = state; + if let Err(error) = line_endings::preserve(record) { + self.units[unit].failure.get_or_insert_with(|| { + format!("Cannot preserve line endings in {}: {error}", key.display()) + }); + } + } Ok(state) if record.expected.matches(&state) => {} Ok(_) => { record.conflict = Some("File changed outside a package-manager command".into()); @@ -1307,6 +1316,7 @@ fn print_backup( } } +mod line_endings; mod mise; mod mise_lock; mod scope; diff --git a/src/install/transaction/line_endings.rs b/src/install/transaction/line_endings.rs new file mode 100644 index 0000000..e262670 --- /dev/null +++ b/src/install/transaction/line_endings.rs @@ -0,0 +1,180 @@ +//! Keep the pre-install newline convention when a manager rewrites a text file. +use super::*; + +pub(super) fn preserve(record: &mut FileRecord) -> io::Result<()> { + // Known binary manager files and arbitrary mise sidecar metadata are opaque. + let path = &record.guards[0].alias; + if path + .extension() + .is_some_and(|ext| ext == "jar" || ext == "lockb") + || record.guards.iter().any(|guard| { + guard.alias.ancestors().any(|path| { + path.file_name().is_some_and(|name| name == "locks") + && path + .parent() + .and_then(Path::file_name) + .is_some_and(|name| name == "mise" || name == ".mise") + }) + }) + { + return Ok(()); + } + let Some((before, after)) = record + .before + .bytes + .as_deref() + .zip(record.expected.bytes.as_deref()) + else { + return Ok(()); + }; + let Some(bytes) = convert(before, after) else { + return Ok(()); + }; + // Only a state already adopted from a manager command or an audit receipt + // may be normalized. Never use normalized text for ownership comparisons. + for guard in &record.guards { + guard.check()?; + } + write_checked( + &record.guards[0], + &record.expected, + &bytes, + record.expected.permissions.as_ref(), + )?; + record.expected.bytes = Some(bytes); + if !record.expected.matches(&read_record(record)?) { + return Err(io::Error::other( + "File changed after preserving line endings", + )); + } + Ok(()) +} + +fn text(bytes: &[u8]) -> bool { + std::str::from_utf8(bytes).is_ok() + && !bytes.iter().enumerate().any(|(index, &byte)| { + (byte.is_ascii_control() && !matches!(byte, b'\n' | b'\r' | b'\t')) + || (byte == b'\r' && bytes.get(index + 1) != Some(&b'\n')) + }) +} + +fn convert(before: &[u8], after: &[u8]) -> Option> { + if before == after || !text(before) || !text(after) { + return None; + } + let mut style = None; + for (index, &byte) in before.iter().enumerate() { + if byte == b'\n' { + let crlf = index > 0 && before[index - 1] == b'\r'; + if style.is_some_and(|known| known != crlf) { + return None; + } + style = Some(crlf); + } + } + let crlf = style?; + let mut converted = Vec::with_capacity(after.len()); + for &byte in after { + if byte == b'\r' { + continue; + } + if byte == b'\n' && crlf { + converted.push(b'\r'); + } + // A trailing newline is neither added nor removed. + converted.push(byte); + } + (converted != after).then_some(converted) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(path: &Path, before: Option<&[u8]>, after: &[u8]) -> FileRecord { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + if let Some(before) = before { + fs::write(path, before).unwrap(); + } + let guard = FileGuard::capture(path).unwrap(); + let before = guard.read().unwrap(); + fs::write(path, after).unwrap(); + let expected = guard.read().unwrap(); + FileRecord { + guards: vec![guard], + before, + expected, + conflict: None, + parent_permissions: vec![], + } + } + + #[test] + fn skips_new_files_binary_paths_and_mise_sidecars() { + let temp = tempfile::tempdir().unwrap(); + let after = b"new\r\n"; + for path in [ + "gradle-wrapper.jar", + "bun.lockb", + ".mise/locks/mise/node", + "mise/locks/config/node", + ] { + let path = temp.path().join(path); + let mut record = record(&path, Some(b"old\n"), after); + preserve(&mut record).unwrap(); + assert_eq!(fs::read(path).unwrap(), after); + } + let path = temp.path().join("new.lock"); + let mut record = record(&path, None, after); + preserve(&mut record).unwrap(); + assert_eq!(fs::read(path).unwrap(), after); + } + + #[test] + fn refuses_even_a_newline_only_external_edit() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("Cargo.lock"); + let mut record = record(&path, Some(b"old\n"), b"new\r\n"); + // Normalizing first would mask this unowned change. + fs::write(&path, b"new\n").unwrap(); + assert!(preserve(&mut record).is_err()); + assert_eq!(fs::read(path).unwrap(), b"new\n"); + assert_eq!( + record.expected.bytes.as_deref(), + Some(b"new\r\n".as_slice()) + ); + } + + #[test] + fn converts_newlines_without_changing_other_bytes_or_the_final_newline() { + for tail in ["", "\n"] { + let after = format!("# 日本語\r\nversion = 4{tail}"); + assert_eq!( + convert(b"old\n", after.as_bytes()).unwrap(), + format!("# 日本語\nversion = 4{tail}").as_bytes() + ); + let after = format!("# 日本語\nversion = 4{tail}"); + assert_eq!( + convert(b"old\r\n", after.as_bytes()).unwrap(), + format!("# 日本語\r\nversion = 4{}", tail.replace('\n', "\r\n")).as_bytes() + ); + } + } + + #[test] + fn ambiguous_or_binary_input_is_left_untouched() { + for before in [ + b"".as_slice(), + b"no newline", + b"mixed\r\nlines\n", + b"old\rline", + b"\xff\n", + b"\0\n", + ] { + assert!(convert(before, b"new\r\n").is_none()); + } + for after in [b"\xff\r\n".as_slice(), b"\0\r\n", b"new\rold\r\n", b"new\n"] { + assert!(convert(b"old\n", after).is_none()); + } + } +} diff --git a/src/install/transaction/tests.rs b/src/install/transaction/tests.rs index 7109b0e..47f64bc 100644 --- a/src/install/transaction/tests.rs +++ b/src/install/transaction/tests.rs @@ -86,6 +86,192 @@ fn success(command: &PreparedInstall) -> InstallResult { ) } +#[test] +fn line_ending_write_failure_is_reported_and_keeps_the_adopted_bytes() { + for audit in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path(); + let mut result = result(); + node(&mut result, directory); + let manifest = directory.join("package.json"); + let lock = directory.join("package-lock.json"); + fs::write(&lock, "old lock\n").unwrap(); + let writable = fs::metadata(&lock).unwrap().permissions(); + let mut readonly = writable.clone(); + readonly.set_readonly(true); + fs::set_permissions(&lock, readonly.clone()).unwrap(); + // Privileged Unix processes can write through read-only permissions. + let protected = fs::OpenOptions::new().write(true).open(&lock).is_err(); + if !audit { + fs::set_permissions(&lock, writable.clone()).unwrap(); + } + if !protected { + fs::set_permissions(&lock, writable).unwrap(); + continue; + } + let plan = plan(vec![job( + directory, + Language::Node, + std::slice::from_ref(&manifest), + )]); + let mut tx = InstallTransactions::begin(&plan, &result, directory); + let after = b"new lock\r\n"; + if audit { + tx.execute(success); + fs::set_permissions(&lock, writable.clone()).unwrap(); + fs::write(&lock, after).unwrap(); + fs::set_permissions(&lock, readonly).unwrap(); + let error = tx + .accept_audit_lock(&lock, b"old lock\n", after) + .unwrap_err(); + assert!( + error.contains("Cannot preserve lockfile line endings"), + "{error}" + ); + } else { + tx.execute(|command| { + fs::write(&lock, after).unwrap(); + fs::set_permissions(&lock, readonly.clone()).unwrap(); + success(command) + }); + assert!(tx.pending_report().has_install_failures()); + } + assert_eq!(fs::read(&lock).unwrap(), after); + assert_eq!( + tx.files[&identity(&lock)].expected.bytes.as_deref(), + Some(after.as_slice()) + ); + // Restore permissions so the temporary directory can be removed on Windows. + fs::set_permissions(&lock, writable).unwrap(); + } +} + +#[test] +fn failed_install_restores_original_bytes_after_newline_preservation() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path(); + let mut result = result(); + node(&mut result, directory); + let manifest = directory.join("package.json"); + let lock = directory.join("package-lock.json"); + fs::write(&lock, "old lock\n").unwrap(); + let original = result.write_results[0] + .journal + .as_ref() + .unwrap() + .before + .clone(); + let plan = plan(vec![job( + directory, + Language::Node, + std::slice::from_ref(&manifest), + )]); + let mut tx = InstallTransactions::begin(&plan, &result, directory); + tx.execute(|command| { + fs::write( + &manifest, + "{\r\n \"dependencies\": {\"example\":\"2.0.0\"}\r\n}\r\n", + ) + .unwrap(); + fs::write(&lock, "new lock\r\n").unwrap(); + failure(command) + }); + assert_eq!(fs::read(&lock).unwrap(), b"new lock\n"); + let report = tx.rollback(); + assert!(report.has_install_failures()); + assert_eq!(fs::read(&lock).unwrap(), b"old lock\n"); + assert_eq!(fs::read(&manifest).unwrap(), original); +} + +#[test] +fn cargo_install_and_age_audit_preserve_each_files_line_endings() { + for newline in ["\n", "\r\n"] { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path(); + let manifest = directory.join("Cargo.toml"); + let lock = directory.join("Cargo.lock"); + let mut result = result(); + let original = "[package]\nname = \"example\"\nversion = \"0.1.0\"\n[dependencies]\nexample = \"=1.0.0\"\n".replace('\n', newline); + update(&mut result, &manifest, Language::Rust, &original, "example"); + let written = original.replace("=1.0.0", "=2.0.0"); + assert_eq!(fs::read(&manifest).unwrap(), written.as_bytes()); + fs::create_dir(directory.join("src")).unwrap(); + fs::write(directory.join("src/lib.rs"), "").unwrap(); + fs::write(&lock, "version = 4\n".replace('\n', newline)).unwrap(); + let plan = plan(vec![job( + directory, + Language::Rust, + std::slice::from_ref(&manifest), + )]); + let mut tx = InstallTransactions::begin(&plan, &result, directory); + let other = if newline == "\n" { "\r\n" } else { "\n" }; + let installed = format!("# installed{other}version = 4{other}"); + assert_eq!(tx.commands().count(), 1); + tx.execute(|command| { + fs::write(&lock, &installed).unwrap(); + success(command) + }); + let expected = format!("# installed{newline}version = 4{newline}"); + assert_eq!(fs::read(&lock).unwrap(), expected.as_bytes()); + tx.check_lock(&lock, Some(expected.as_bytes())).unwrap(); + // The audited Cargo operation may rewrite the lock a second time. + let audited = format!("# audited{other}version = 4{other}"); + fs::write(&lock, &audited).unwrap(); + tx.accept_audit_lock(&lock, expected.as_bytes(), audited.as_bytes()) + .unwrap(); + let expected = format!("# audited{newline}version = 4{newline}"); + assert_eq!(fs::read(&lock).unwrap(), expected.as_bytes()); + tx.check_lock(&lock, Some(expected.as_bytes())).unwrap(); + assert!(!tx.rollback().has_failures()); + assert_eq!(fs::read(&manifest).unwrap(), written.as_bytes()); + } +} + +#[test] +fn mise_platform_repair_preserves_manifest_and_lock_line_endings_independently() { + for (manifest_newline, lock_newline) in [("\n", "\r\n"), ("\r\n", "\n")] { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path(); + let manifest = directory.join("mise.toml"); + let lock = directory.join("mise.lock"); + let mut result = result(); + let original = "[tools]\nnode = \"1.0.0\"\n".replace('\n', manifest_newline); + update(&mut result, &manifest, Language::Mise, &original, "node"); + let platforms = ["linux-x64", "windows-x64"]; + fs::write( + &lock, + platform_lock("1.0.0", &platforms).replace('\n', lock_newline), + ) + .unwrap(); + let plan = plan(vec![job( + directory, + Language::Mise, + std::slice::from_ref(&manifest), + )]); + let mut tx = InstallTransactions::begin(&plan, &result, directory); + let other = if lock_newline == "\n" { "\r\n" } else { "\n" }; + let mut calls = 0; + tx.execute(|command| { + calls += 1; + let targets = if calls == 1 { &[][..] } else { &platforms[..] }; + fs::write(&lock, platform_lock("2.0.0", targets).replace('\n', other)).unwrap(); + success(command) + }); + assert_eq!(calls, 2); + assert!(!tx.rollback().has_failures()); + assert_eq!( + fs::read(&manifest).unwrap(), + original.replace("1.0.0", "2.0.0").as_bytes() + ); + assert_eq!( + fs::read(&lock).unwrap(), + platform_lock("2.0.0", &platforms) + .replace('\n', lock_newline) + .as_bytes() + ); + } +} + fn platform_lock(version: &str, platforms: &[&str]) -> String { let mut lock = format!( "lockfile_version = 2\n[[tools.node]]\nversion = \"{version}\"\nbackend = \"core:node\"\nspecifiers = [\"{version}\"]\n"