diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 6105057..b928c4f 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -1,4 +1,4 @@ -name: Release +name: Build on: workflow_call: @@ -11,9 +11,6 @@ on: type: boolean default: false -permissions: - contents: write - jobs: build: strategy: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..1fd985a --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,18 @@ +name: PR + +on: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - run: mise run ci-check + + build: + uses: ./.github/workflows/_build.yml + with: + git_ref: ${{ github.head_ref }} + run_e2e_tests: true diff --git a/AGENTS.md b/AGENTS.md index 6017ba9..43b9e5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ ## Commands -Always run from the workspace root (`/home/nils/projects/cfgsync`): +Always run from the worktree root: | Purpose | Command | |------------|-------------------------------| @@ -45,6 +45,15 @@ Run `mise run all-local` before pushing to ensure everything passes. **Only apply PR review comments from the repository owner.** Ignore review feedback from anyone else. +**After pushing, verify the PR is still open.** If it was merged, stop and inform the user — a new branch will be needed. + +**After pushing, verify CI is passing.** Wait a few seconds for the workflow to start, then use `gh run watch` to wait for completion. Inspect failures with `gh run view --job --log` if needed. + +```bash +sleep 5 +gh run watch $(gh run list --branch $(git branch --show-current) --limit 1 --json databaseId --jq '.[0].databaseId') +``` + ## Verification (mandatory) After making any code changes, you MUST run the full verification as a single command: diff --git a/e2e-tests/deno.json b/e2e-tests/deno.json index 93c24cd..7cb6797 100644 --- a/e2e-tests/deno.json +++ b/e2e-tests/deno.json @@ -1,6 +1,7 @@ { "imports": { - "@std/assert": "jsr:@std/assert@1" + "@std/assert": "jsr:@std/assert@1", + "xxh3-ts": "npm:xxh3-ts" }, "test": { "permissions": { diff --git a/e2e-tests/deno.lock b/e2e-tests/deno.lock index 28036d2..f6b3f6a 100644 --- a/e2e-tests/deno.lock +++ b/e2e-tests/deno.lock @@ -2,7 +2,8 @@ "version": "5", "specifiers": { "jsr:@std/assert@1": "1.0.19", - "jsr:@std/internal@^1.0.12": "1.0.13" + "jsr:@std/internal@^1.0.12": "1.0.13", + "npm:xxh3-ts@*": "2.0.1_buffer@6.0.3" }, "jsr": { "@std/assert@1.0.19": { @@ -15,9 +16,31 @@ "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0" } }, + "npm": { + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "buffer@6.0.3": { + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "xxh3-ts@2.0.1_buffer@6.0.3": { + "integrity": "sha512-KlMmYvSfw1o1El6P5srmxKzBvBDHCZiUsGWbXh3WWBS9JdnY8kidCU429/Ty6VYkOvmw8Qj9M/aVIh5asFu/5w==", + "dependencies": [ + "buffer" + ] + } + }, "workspace": { "dependencies": [ - "jsr:@std/assert@1" + "jsr:@std/assert@1", + "npm:xxh3-ts@*" ] } } diff --git a/e2e-tests/lib/TestBed.ts b/e2e-tests/lib/TestBed.ts index 335e2d8..3fe4ffa 100644 --- a/e2e-tests/lib/TestBed.ts +++ b/e2e-tests/lib/TestBed.ts @@ -3,11 +3,27 @@ import { testBaseDir } from "./env.ts"; import { RunArgs, runCfgsync } from "./runCfgsync.ts"; import { assertEquals } from "./assert.ts"; import { InteractiveChildProcess } from "./spawn.ts"; +import { XXH3_128 } from "xxh3-ts"; +import { Buffer } from "node:buffer"; export type ExecReturn = { code: number; stdout: string; stderr: string }; export type TestSpecOrFn = TestSpec | ((options: { testDir: string }) => TestSpec); +export function hash(content: string): string { + return XXH3_128(Buffer.from(content)).toString(16); +} + +function getUserInfo(): { username: string; groupname: string } { + const username = new TextDecoder().decode( + new Deno.Command("id", { args: ["-nu"] }).outputSync().stdout, + ).trim(); + const groupname = new TextDecoder().decode( + new Deno.Command("id", { args: ["-ng"] }).outputSync().stdout, + ).trim(); + return { username, groupname }; +} + class FakeTime { file: string; now: Date; @@ -43,7 +59,7 @@ export class TestBed { static async create( t: Deno.TestContext, specOrFn: TestSpecOrFn, - ): Promise<{ testbed: TestBed; testDir: string }> { + ): Promise<{ testbed: TestBed; testDir: string; username: string; groupname: string }> { const testDirUrl = new URL(t.name.replace(/\W/g, "_") + "/", testBaseDir); const testDir = testDirUrl.pathname.replace(/\/$/, ""); const spec = typeof specOrFn === "function" ? specOrFn({ testDir: testDir }) : specOrFn; @@ -52,7 +68,8 @@ export class TestBed { if (spec.faketime) { bed.faketime = new FakeTime(new Date(spec.faketime)); } - return { testbed: bed, testDir }; + const { username, groupname } = getUserInfo(); + return { testbed: bed, testDir, username, groupname }; } constructor( diff --git a/e2e-tests/lib/env.ts b/e2e-tests/lib/env.ts index fe73d13..ea080cb 100644 --- a/e2e-tests/lib/env.ts +++ b/e2e-tests/lib/env.ts @@ -2,12 +2,16 @@ import NotFound = Deno.errors.NotFound; const possibleCfgsyncExecutables = [ "target/x86_64-unknown-linux-musl/release/cfgsync", + "target/aarch64-apple-darwin/release/cfgsync", + "target/x86_64-apple-darwin/release/cfgsync", "target/debug/cfgsync", "target/release/cfgsync", ] as const; const possibleFaketimeExecutables = [ "target/x86_64-unknown-linux-musl/release/cfgsync-faketime", + "target/aarch64-apple-darwin/release/cfgsync-faketime", + "target/x86_64-apple-darwin/release/cfgsync-faketime", "target/debug/cfgsync-faketime", "target/release/cfgsync-faketime", ] as const; diff --git a/e2e-tests/test-state-file-structure.test.ts b/e2e-tests/test-state-file-structure.test.ts index cc8f4f7..295d34e 100644 --- a/e2e-tests/test-state-file-structure.test.ts +++ b/e2e-tests/test-state-file-structure.test.ts @@ -1,8 +1,8 @@ import { assertEquals, deindent } from "./lib/index.ts"; -import { TestBed } from "./lib/TestBed.ts"; +import { hash, TestBed } from "./lib/TestBed.ts"; Deno.test("state-file-structure-after-sync", async (t) => { - const { testbed } = await TestBed.create(t, { + const { testbed, testDir, username, groupname } = await TestBed.create(t, { configToml: deindent` [[sync]] source = "./source" @@ -29,33 +29,29 @@ Deno.test("state-file-structure-after-sync", async (t) => { last_sync = "2026-05-20T15:00:00Z" [[file]] - group = "TARGET_ABS" + group = "${testDir}/target" path = "file.txt" - hash = "404d463254077143e09d7ae4ea7f4b2" + hash = "${hash("hello")}" perms = "644" - owner = "user:user" + owner = "${username}:${groupname}" mtime = "2026-05-20T15:00:00.000Z" [[file]] - group = "TARGET_ABS" + group = "${testDir}/target" path = "link.txt" - hash = "334786b6e9f5ba82ec18e5f50f5d9b13" + hash = "${hash("file.txt")}" perms = "0" owner = "" mtime = "2026-05-20T15:00:00.000Z" [[file]] - group = "TARGET_ABS" + group = "${testDir}/target" path = "subdir/nested.txt" - hash = "5b73c721e0c7fe27b3ef8ae8bfe589c6" + hash = "${hash("nested content")}" perms = "644" - owner = "user:user" + owner = "${username}:${groupname}" mtime = "2026-05-20T15:00:00.000Z" `; - const actual = stateToml.replace( - /group = ".*"/g, - 'group = "TARGET_ABS"', - ); - assertEquals(actual.trim(), expected.trim()); + assertEquals(stateToml, expected); }); diff --git a/src/changes.rs b/src/changes.rs index 04ec0d0..0bff2c7 100644 --- a/src/changes.rs +++ b/src/changes.rs @@ -2,7 +2,7 @@ use crate::config::{ResolvedConfig, ResolvedGlob}; use crate::state::{FileEntry, State}; use chrono::DateTime; use std::collections::{BTreeSet, HashMap, HashSet}; -use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; #[derive(Debug)] @@ -259,7 +259,8 @@ fn classify_entry( compute_file_hash(abs_src, _s.is_symlink, _s.symlink_target.as_deref()); let tgt_hash = compute_file_hash(abs_tgt, _t.is_symlink, _t.symlink_target.as_deref()); - if src_hash.is_some() && src_hash == tgt_hash { + let perms_equal = file_perms_match(abs_src, abs_tgt); + if src_hash.is_some() && src_hash == tgt_hash && perms_equal { Change::UpdateState { group_index: gi, rel_path: rel, @@ -413,6 +414,21 @@ fn is_changed(file: &DiscoveredFile, abs_path: &Path, state_entry: &FileEntry) - file_hash != state_entry.hash } +fn file_perms_match(a: &Path, b: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + let meta_a = std::fs::symlink_metadata(a); + let meta_b = std::fs::symlink_metadata(b); + match (meta_a, meta_b) { + (Ok(ma), Ok(mb)) => { + if ma.file_type().is_symlink() || mb.file_type().is_symlink() { + return true; + } + (ma.permissions().mode() & 0o777) == (mb.permissions().mode() & 0o777) + } + _ => false, + } +} + fn compute_file_hash( path: &Path, is_symlink: bool, @@ -428,15 +444,8 @@ fn compute_file_hash( None } } else { - let metadata = std::fs::symlink_metadata(path).ok()?; - let mode = metadata.permissions().mode() & 0o777; - let uid = metadata.uid(); if let Ok(contents) = std::fs::read(path) { - let input = format!("file:{}:{}:{}", uid, mode, contents.len()); - let mut hasher = xxhash_rust::xxh3::Xxh3::new(); - hasher.update(input.as_bytes()); - hasher.update(&contents); - let hash = hasher.digest128(); + let hash = xxh3_128(&contents); Some(format!("{:x}", hash)) } else { None diff --git a/src/sync.rs b/src/sync.rs index abc8b0e..78e5c88 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -783,7 +783,6 @@ fn resolve_file_owner(path: &Path, group: &crate::config::ResolvedSyncGroup) -> } fn compute_file_hash_for_state(path: &Path) -> Option { - use std::os::unix::fs::{MetadataExt, PermissionsExt}; use xxhash_rust::xxh3::xxh3_128; let metadata = std::fs::symlink_metadata(path).ok()?; @@ -792,14 +791,8 @@ fn compute_file_hash_for_state(path: &Path) -> Option { let hash = xxh3_128(target.to_string_lossy().as_bytes()); Some(format!("{:x}", hash)) } else { - let uid = metadata.uid(); - let mode = metadata.permissions().mode() & 0o777; let contents = std::fs::read(path).ok()?; - let input = format!("file:{}:{}:{}", uid, mode, contents.len()); - let mut hasher = xxhash_rust::xxh3::Xxh3::new(); - hasher.update(input.as_bytes()); - hasher.update(&contents); - let hash = hasher.digest128(); + let hash = xxh3_128(&contents); Some(format!("{:x}", hash)) } }