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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ apps/landing/
# Never ship history or local tooling state into the context.
.git/
.github/
.claude/
**/node_modules/
**/.DS_Store

Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/deploy-landing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ name: Deploy landing
# Build the Nuxt landing and publish the compiled static site to a branch that
# holds only the build output:
# - push to main → pages/landing (production)
# - push to dev → pages/landing-dev (staging, to preview before main)
# - push to develop → pages/landing-dev (staging, to preview before main)
on:
push:
branches: [main, dev]
branches: [main, develop]
workflow_dispatch:

# One deploy per source branch at a time; don't let main and dev cancel each other.
# One deploy per source branch at a time; don't let main and develop cancel each other.
concurrency:
group: deploy-landing-${{ github.ref_name }}
cancel-in-progress: true
Expand Down Expand Up @@ -39,7 +39,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# main goes to production; every other branch (dev) goes to staging.
# main goes to production; every other branch (develop) goes to staging.
# The target branch keeps its -dev name: it is what serves the staging
# site, and renaming it would change that URL for no gain.
if [ "$GITHUB_REF_NAME" = "main" ]; then
TARGET="pages/landing"
else
Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/gitflow.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
name: Gitflow

# Enforce where changes come from:
# - PRs into dev must come from a gitflow branch (feature/*, hotfix/* or
# - PRs into develop must come from a gitflow branch (feature/*, hotfix/* or
# release/*), or from main itself (a post-release back-merge).
# - PRs into main are releases and must come from dev, only.
# - PRs into main are releases and must come from develop, only.
# Both checks are required by the branch rulesets, so a PR from any other
# source branch cannot be merged.
on:
pull_request:
branches: [dev, main]
branches: [develop, main]

jobs:
branch-name:
name: gitflow branch name
if: github.base_ref == 'dev'
if: github.base_ref == 'develop'
runs-on: ubuntu-latest
steps:
- name: Check the head branch follows gitflow
Expand All @@ -24,7 +24,7 @@ jobs:
feature/*|hotfix/*|release/*|main)
echo "ok: $HEAD_REF" ;;
*)
echo "PRs into dev must come from a feature/*, hotfix/* or release/* branch (got: $HEAD_REF)"
echo "PRs into develop must come from a feature/*, hotfix/* or release/* branch (got: $HEAD_REF)"
exit 1 ;;
esac

Expand All @@ -33,13 +33,13 @@ jobs:
if: github.base_ref == 'main'
runs-on: ubuntu-latest
steps:
- name: Check the release comes from dev
- name: Check the release comes from develop
env:
HEAD_REF: ${{ github.head_ref }}
run: |
if [ "$HEAD_REF" = "dev" ]; then
echo "ok: release PR from dev"
if [ "$HEAD_REF" = "develop" ]; then
echo "ok: release PR from develop"
else
echo "PRs into main are releases and must come from dev (got: $HEAD_REF)"
echo "PRs into main are releases and must come from develop (got: $HEAD_REF)"
exit 1
fi
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ concurrency:
jobs:
# A release must correspond to a released state of the code: refuse any
# v* tag whose commit is not part of main's history (e.g. a tag placed on
# dev by mistake). The publish job requires this to pass.
# develop by mistake). The publish job requires this to pass.
verify:
name: tag is on main
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/test-and-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ name: test-and-build
# landing has no test suite and is built by deploy-landing.yml instead.
on:
push:
branches: [main, dev]
branches: [main, develop]
# `labeled` is not in the default set, and without it the `full-matrix` label
# below would be a documented escape hatch that never fires: adding a label
# would change nothing until the next push.
Expand Down Expand Up @@ -245,11 +245,11 @@ jobs:
# times a Linux one in billed minutes and feature work does not need them.
#
# They run when the code is on, or heading to, a shipping branch:
# - any push to dev or main (the `on:` block already limits pushes to those)
# - a pull request FROM dev (the release PR into main), from main (a
# - any push to develop or main (the `on:` block already limits pushes to those)
# - a pull request FROM develop (the release PR into main), from main (a
# back-merge) or from a release/* branch
# A pull request from feature/* or hotfix/* therefore stays Linux only, and
# picks these up when it lands on dev. When such a branch does touch
# picks these up when it lands on develop. When such a branch does touch
# platform-specific code, label the pull request `full-matrix` to ask for
# them anyway rather than renaming the branch.
#
Expand All @@ -263,7 +263,7 @@ jobs:
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
github.head_ref == 'dev' ||
github.head_ref == 'develop' ||
github.head_ref == 'main' ||
startsWith(github.head_ref, 'release/') ||
contains(github.event.pull_request.labels.*.name, 'full-matrix')
Expand Down Expand Up @@ -534,22 +534,22 @@ jobs:
run: make desktop/compile

# The same compile on the other two platforms it ships to, on the release
# path only: a release/* branch, and the dev to main pull request itself.
# path only: a release/* branch, and the develop to main pull request itself.
#
# That second one is not optional. The pull request into main IS the release
# gate, the last thing that runs before a tag exists, so a desktop build
# skipping there would mean the app was never compiled for macOS or Windows
# on the commit about to ship. Its head branch is dev, not release/*, so it
# on the commit about to ship. Its head branch is develop, not release/*, so it
# needs naming separately.
#
# Not on dev, deliberately. The test jobs above already compile this crate on
# Not on develop, deliberately. The test jobs above already compile this crate on
# both platforms: `make desktop/test-rust` runs `cargo test` inside src-tauri,
# which goes through generate_context!(), runs build.rs (so rc.exe, the icon
# and the manifest on Windows) and links executables. All this adds is the
# release profile, which for this crate means lto, codegen-units = 1 and
# panic = "abort": a real difference, but a narrow one, and the slowest kind
# of build there is. Paying ten to fifteen minutes per platform on every push
# to dev for that is not a good trade.
# to develop for that is not a good trade.
#
# On a release branch it is, because the alternative is finding out after the
# tag is pushed. Note what it still does not cover: it compiles rather than
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
target/
.DS_Store
CLAUDE.md
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,18 @@ Formats: `zip` (default, or inferred from `-o`'s extension), `7z`, `tar`. Level
next to the source; override with `-o`. It **won't overwrite** an existing
archive unless you pass `--force`, and `--force` still refuses to write onto its
own source or onto a file inside the folder being compressed, because either
would destroy the data instead of archiving it. Run `collapse --help` for the
full surface.
would destroy the data instead of archiving it.

**Every local compression is read back before it is reported.** A compressor
here finalises on drop, so a run that dies partway through still closes out an
archive that opens cleanly and is quietly missing whatever had not been written
yet — nothing about the file itself says so. By default the check is the
archive's own listing, which is what catches that. `--verify` upgrades it to
reading every entry back, roughly twice the work, which also checks the
per-entry checksums zip and 7z store (tar stores none). It cannot be combined
with `--server`, since the archive is built on the far side.

Run `collapse --help` for the full surface.

Prebuilt macOS binaries (Apple Silicon and Intel tarballs, with sha256
checksums) are attached to every
Expand Down Expand Up @@ -188,8 +198,8 @@ Requires **Rust 1.88+** (2021 edition).

```bash
make build # build the Rust crates
make test # run every suite (620 Rust tests + 116 Vitest cases)
make test/rust # only the Rust tests that need no Node toolchain (505)
make test # run every suite (621 Rust tests + 116 Vitest cases)
make test/rust # only the Rust tests that need no Node toolchain (506)
```

`make test` includes the desktop app's own Rust suite, which compiles Tauri, so
Expand Down Expand Up @@ -244,9 +254,9 @@ make fmt make lint # format / clippy both Rust workspaces
make fmt/check # fail instead of reformatting (the tree is rustfmt clean)
```

Work happens on `dev`, merged into `main` per release (see
Work happens on `develop`, merged into `main` per release (see
[git flow](docs/git_flow.md)). CI invokes these same `make` targets on every
pull request and on pushes to `dev` and `main`.
pull request and on pushes to `develop` and `main`.

## License

Expand Down
19 changes: 10 additions & 9 deletions apps/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,17 +437,18 @@ fn run_extract(archive: PathBuf, output_dir: PathBuf) -> Result<Outcome, CliErro
// about names. Core skips its own planning pass for the same reason.
let report = unwritable_names_with(&archive, rules).unwrap_or_default();

// Refused only when a character needs a replacement, which is a question,
// and a question needs someone to ask. The other two problems (a trailing
// dot or space, a reserved device name) have one correct answer that needs
// nobody: refusing those would leave a Windows user with no way to extract
// an archive whose only fault is a file called `aux.log`, while the
// desktop app would open it without a word.
if !report.characters.is_empty() {
// Every problem, not only the ones that used to carry a question. There is
// no question left to ask: core refuses an entry it cannot write under the
// archive's own name rather than adjusting it, so a trailing dot and a
// reserved device stop the extraction exactly as a colon does. Refusing
// here as well is not redundant — it is what lets the message name *every*
// entry at fault from the one listing, where core stops at the first.
if !report.is_empty() {
return Err(CliError::UnwritableEntries { archive, report });
}
// Worked out before extracting, from the same listing and the same rules
// the engine will use, so what is reported is what lands on disk.
// Nothing is adjusted any more, so this is provably empty by the time it is
// reached: the report above was empty, and adjustments come from the report.
// Kept until `Outcome::Extracted` loses the field.
let adjusted = adjustments(&report, rules);

let files = extract(&archive, &output_dir)?;
Expand Down
66 changes: 33 additions & 33 deletions apps/cli/tests/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use std::io::Write as _;
use std::path::{Path, PathBuf};

use clap::Parser;
use collapse_cli::{adjustments, run, Adjustment, Cli, CliError};
use collapse_cli::{run, Cli, CliError};
use collapse_core::{NameReport, NameRules};

fn run_err(args: &[&str]) -> CliError {
Expand Down Expand Up @@ -143,50 +143,50 @@ fn the_refusal_counts_the_entries_each_character_holds_up() {
);
}

/// A trailing dot and a device name have one correct answer and nobody to ask,
/// so they are not what the refusal is about. Refusing them too would leave a
/// Windows user unable to extract an archive whose only fault is a file called
/// `aux.log`, which the desktop app would open without a word.
/// A trailing dot and a device name used to be adjusted rather than refused, on
/// the reasoning that they had one correct answer and nobody to ask. Core
/// adjusts nothing now, so `run_extract` refuses on the whole report instead of
/// on its `characters` half, and these three entries are exactly what that
/// widening is about: an archive whose only fault is a file called `aux.log` is
/// refused on Windows rather than arriving as `aux_.log`.
///
/// Judged through `NameReport` rather than by running the command, because the
/// faults are Windows-only and this suite runs on Linux.
#[test]
fn a_name_that_needs_no_answer_is_adjusted_rather_than_refused() {
fn a_name_that_needs_no_answer_is_refused_like_any_other() {
let windows = NameRules::windows();
let report = NameReport::of(&["notes.txt.", "CON.txt", "aux.log", "fine.txt"], windows);

// The half the CLI used to consult is empty: not one of these is a question
// anybody could be asked. Under the old rule the archive extracted.
assert!(report.characters.is_empty(), "none of these is a question");
// The half it consults now is not, which is the whole of the change.
assert!(!report.is_empty(), "the archive is refused all the same");

let named: Vec<&str> = report.entries.iter().map(|e| e.entry.as_str()).collect();
assert_eq!(
adjustments(&report, windows),
vec![
Adjustment {
entry: "notes.txt.".to_string(),
written: "notes.txt".to_string(),
},
Adjustment {
entry: "CON.txt".to_string(),
written: "CON_.txt".to_string(),
},
Adjustment {
entry: "aux.log".to_string(),
written: "aux_.log".to_string(),
},
],
"the device keeps the extension that says what the file is, and the writable name is absent"
named,
["notes.txt.", "CON.txt", "aux.log"],
"every offending entry, and the writable one absent"
);
}

/// An entry needing an answer has no adjustment to report, because there is no
/// answer to apply. That is what keeps the two lists disjoint: what `run`
/// refuses is exactly what this cannot rewrite.
/// The two kinds of fault now arrive at the same place, which is what stops the
/// refusal being half a list. A report mixing one of each names both, so a
/// user is not refused for `what?.txt`, fixed nothing, and then refused again
/// for `notes.txt.`.
#[test]
fn an_entry_needing_a_replacement_has_no_adjustment() {
fn a_report_mixing_both_kinds_of_fault_names_all_of_them() {
let windows = NameRules::windows();
let report = NameReport::of(&["what?.txt", "notes.txt."], windows);

assert_eq!(
adjustments(&report, windows),
vec![Adjustment {
entry: "notes.txt.".to_string(),
written: "notes.txt".to_string(),
}],
);
assert!(!report.is_empty());
let named: Vec<&str> = report.entries.iter().map(|e| e.entry.as_str()).collect();
assert_eq!(named, ["what?.txt", "notes.txt."]);

let message = windows_refusal("mixed.zip", &["what?.txt", "notes.txt."]);
assert!(message.contains("what?.txt"), "{message}");
assert!(message.contains("notes.txt."), "{message}");
}

// ------------------------------------------------------ end to end, on any host --
Expand Down
Loading
Loading