Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/usage.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 を共有する処理は、まとめて成功か復元にします。失敗した単位の残りの処理は実行しません。独立したプロジェクトの処理は続け、成功した変更を残します。
Expand Down
2 changes: 2 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion src/install/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -1307,6 +1316,7 @@ fn print_backup(
}
}

mod line_endings;
mod mise;
mod mise_lock;
mod scope;
Expand Down
180 changes: 180 additions & 0 deletions src/install/transaction/line_endings.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>> {
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());
}
}
}
Loading
Loading