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
24 changes: 5 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,29 +30,15 @@ Rust toolchain: `1.96.0` (managed by mise). The `x86_64-unknown-linux-musl` targ

**Never commit or push directly to `main`.** All changes must go through branches and pull requests.

When making changes, follow this branch-based workflow:
**ALWAYS use `bin/git-commit-and-push <message>` to commit and push.** This script runs `mise run all-local`,
commits with the given message (conventional commit style), pushes, creates a PR if none exists, and waits for CI.
See `bin/git-commit-and-push --help`.

1. **Create a branch** — use a descriptive name (e.g. `feat/add-watch-mode`, `fix/state-rebuild-dup`).
2. **Work on the branch** — make all changes there.
3. **Keep branch up to date** — always keep your branch based on the latest `origin/main`. Rebase and force-push if necessary.
4. **Commit** all changes to the branch. Write concise commit messages matching the repo's conventional commit style.
5. **Push** the branch to origin.
6. **Create a PR** — use `gh pr create` to open a pull request.

Run `mise run all-local` before pushing to ensure everything passes.

**After completing any change on a branch, always commit and push.** Do not leave uncommitted or unpushed work sitting on the branch.
Use `bin/git-commit-and-push --no-verify <message>` to skip verification (e.g. for doc-only changes).

**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 <id> --job <job-id> --log` if needed.

```bash
sleep 5
gh run watch $(gh run list --branch $(git branch --show-current) --limit 1 --json databaseId --jq '.[0].databaseId')
```
**After pushing, if the PR was merged**, stop and inform the user — a new branch is needed.

## Verification (mandatory)

Expand Down
153 changes: 153 additions & 0 deletions bin/git-commit-and-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/bin/bash
set -euo pipefail


usage() {
echo "Usage: $0 <commit-message>"
echo ""
echo "Stage all files, run full verification, commit, push, and verify CI."
echo ""
echo "Steps:"
echo " 1. mise run all-local (full verification)"
echo " 2. git add -A && git commit -m <message>"
echo " 3. git push origin <current-branch>"
echo " 4. Create or verify PR"
echo " 5. Wait for CI to finish"
echo ""
echo "Options:"
echo " --no-verify Skip verification (commit and push only)"
}

WORKTREE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BRANCH="$(cd "$WORKTREE_ROOT" && git branch --show-current)"
PR_NUMBER=""


detect_ssh_auth() {
for sock in /run/user/$(id -u)/*ssh* /run/user/$(id -u)/*gcr*ssh* /tmp/ssh-*/*.agent.*; do
if [ -S "$sock" ] 2>/dev/null; then
export SSH_AUTH_SOCK="$sock"
return 0
fi
done
echo "Warning: no SSH agent socket found. Push may fail." >&2
return 1
}


run_verification() {
echo "--- Running full verification (mise run all-local)..."
cd "$WORKTREE_ROOT"
if mise run all-local; then
echo "--- Verification passed."
else
echo "--- Verification FAILED. Aborting." >&2
exit 1
fi
}


commit() {
local msg="$1"
cd "$WORKTREE_ROOT"
git add -A
git commit -m "$msg"
}


push() {
cd "$WORKTREE_ROOT"
detect_ssh_auth
git push origin "$BRANCH"
}


ensure_pr() {
cd "$WORKTREE_ROOT"
PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -z "$PR_NUMBER" ]; then
echo "No open PR found for branch '$BRANCH'. Creating one..."
PR_URL=$(gh pr create --fill 2>&1)
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$' || echo "")
if [ -z "$PR_NUMBER" ]; then
echo "Error: Failed to create PR." >&2
echo "$PR_URL" >&2
return 1
fi
echo "PR #$PR_NUMBER created."
else
echo "PR #$PR_NUMBER is still open."
fi
}


wait_for_ci() {
cd "$WORKTREE_ROOT"
local run_id
sleep 5
run_id=$(gh run list --branch "$BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null || echo "")
if [ -z "$run_id" ]; then
echo "Warning: Could not find CI run for branch '$BRANCH'." >&2
return 1
fi
echo "Waiting for CI run #$run_id to complete..."
gh run watch "$run_id"
local conclusion
conclusion=$(gh run view "$run_id" --json conclusion --jq '.conclusion' 2>/dev/null || echo "unknown")
if [ "$conclusion" != "success" ]; then
echo "CI failed (conclusion: $conclusion). Inspect with:" >&2
echo " gh run view $run_id --log | grep -i error" >&2
return 1
fi
echo "All CI jobs passed."
}


main() {
local skip_verify=false
local msg=""

while [ $# -gt 0 ]; do
case "$1" in
--no-verify)
skip_verify=true
shift
;;
--help|-h)
usage
exit 0
;;
*)
if [ -z "$msg" ]; then
msg="$1"
else
usage
exit 1
fi
shift
;;
esac
done

if [ -z "$msg" ]; then
usage
exit 1
fi

if [ "$BRANCH" = "main" ]; then
echo "Error: You are on 'main' branch. Never commit or push directly to main." >&2
exit 1
fi

if [ "$skip_verify" = false ]; then
run_verification
fi

commit "$msg"
push
ensure_pr
wait_for_ci
}


main "$@"
15 changes: 6 additions & 9 deletions docs/algorithm/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ each sync group defines
* optional file owner for files and directories in the target directory
* optional file permissions for files in the target directory
* optional directory permissions for directories in the target directory
* a list of deviating directories when looking at permissions and owner
* a list of deviating directories when looking at owner
* path (no glob)
* optional expected permission
* optional expected owner
Expand Down Expand Up @@ -408,15 +408,12 @@ If it is not possible to run the hook as that user, a warning is printed and the
action can actually be performed before execution (write permissions on state file, parent directory creation, correct
permissions/owner on existing parent directories, file writability, ability to set owner, etc.). This step is not
implemented — the code goes directly from classification (step 3) to execution (step 5).
- **Hash includes permissions and owner**: Section 2.1 describes the state hash as computed from file contents,
permissions, and owner. The current implementation (`compute_file_hash` in `changes.rs:356` and
`compute_file_hash_for_state` in `sync.rs:703`) computes XXH3_128 over file contents only.
- **Permission preset mappings**: The `PermissionPreset` enum (`private`, `shared`, `group`, `group-read`, `public`) is
deserialized from config and stored in `ResolvedGlob` as `file_perms` and `dir_perms`, but the mapping logic (e.g.,
644 → 600 for `private`) is never applied at runtime. Only raw octal `permissions` fields are used.
- **Permission preset `dir_perms` and reverse mapping**: The `PermissionPreset` enum and `map_permissions()` are
implemented and applied for `file_perms` at runtime (in `enforce_permissions_root` and `check_permissions_nonroot`).
However, `dir_perms` is never applied — directories get no permission enforcement. Additionally, the reverse mapping
for `CopyToSource` (deriving source-side 644/755 from configured target perms) is not implemented.
- **Deviating directories validation**: The `deviating` field on sync groups is deserialized from config and stored in
`ResolvedSyncGroup`, but the expected permissions and owner for these directories are never checked or enforced at
runtime.
`ResolvedSyncGroup`, but the expected owner was never checked at runtime.
- **Target-to-source permission/owner validation before sync**: `permissions-and-owner.md` describes that before copying
from target to source, the target file's permissions and owner must be validated against the configured values. If
they don't match, the file should be skipped with a warning. This validation is not implemented — CopyToSource
Expand Down
2 changes: 1 addition & 1 deletion docs/algorithm/permissions-and-owner.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ values that each represent a mapping of permissions. The following values are al
| Value | source-permissions | target-permissions |
|------------------|--------------------|--------------------|
| private | 644 | 600 |
| | 755 | 600 |
| | 755 | 700 |
| shared | 644 | 664 |
| | 755 | 775 |
| group | 644 | 660 |
Expand Down
23 changes: 4 additions & 19 deletions e2e-tests/permissions-and-owner/deviating-dir-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,15 @@ Deno.test("deviating-dir-config", async (t) => {
globs = ["**/*.txt"]

[[sync.deviating]]
path = "/etc/ssh"
permissions = "700"
owner = "root:root"

[[sync.deviating]]
path = "/etc/nginx"
permissions = "755"
path = "./target/special-dir"
owner = "root:root"
`,
files: [
"user:user | 0755 | 0 | config.toml | __CONFIG_TOML__",
"user:user | 0755 | 0 | source/",
"user:user | 0644 | 0 | source/file.txt | hello world",
"user:user | 0755 | 0 | target/",
"user:user | 0755 | 0 | target/special-dir/",
],
});

Expand All @@ -38,18 +33,7 @@ Deno.test("deviating-dir-config", async (t) => {
});

await testbed.run({ args: ["--config", "config.toml", "sync"] });
testbed.assertOutput({
code: 0,
stdout: deindent`
copied file.txt -> target

source -> target: 1
target -> source: 0
deleted target: 0
deleted source: 0
`,
stderr: "",
});
assertEquals(testbed.getExitCode(), 0);

assertEquals(await testbed.readTestDir(), [
"user:user | 0644 | 0 | config.cfgsync.state | CFGSYNC_STATE",
Expand All @@ -58,5 +42,6 @@ Deno.test("deviating-dir-config", async (t) => {
"user:user | 0644 | 0 | source/file.txt | hello world",
"user:user | 0755 | 0 | target/",
"user:user | 0644 | 0 | target/file.txt | hello world",
"user:user | 0755 | 0 | target/special-dir/",
]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Deno.test("nonroot-permission-warning", async (t) => {
permission skips: 1
`,
stderr: deindent`
Permission warning: 'file.conf' has 0o644, should be 0o600 (run as root to fix)
Permission warning: 'file.conf' has 644, should be 600 (run as root to fix)
`,
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Deno.test("permission-preset-shared", async (t) => {
permission skips: 1
`,
stderr: deindent`
Permission warning: 'file.txt' has 0o644, should be 0o664 (run as root to fix)
Permission warning: 'file.txt' has 644, should be 664 (run as root to fix)
`,
});

Expand Down
2 changes: 1 addition & 1 deletion e2e-tests/test-copy-to-source-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Deno.test({
"user:user | 0755 | 0 | config.toml | __CONFIG_TOML__",
"user:user | 0755 | 0 | source/",
"user:user | 0755 | 0 | target/",
"user:user | 0644 | 0 | target/file.txt | target-only file",
"root:root | 0644 | 0 | target/file.txt | target-only file",
],
});

Expand Down
36 changes: 36 additions & 0 deletions e2e-tests/test-copy-to-source-permission-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { deindent } from "./lib/index.ts";
import { TestBed } from "./lib/TestBed.ts";

Deno.test("copy-to-source-permission-check", async (t) => {
const { testbed } = await TestBed.create(t, {
configToml: deindent`
[[sync]]
source = "./source"
target = "./target"
file_perms = "private"
globs = ["**/*.conf"]
`,
files: [
"user:user | 0755 | 0 | config.toml | __CONFIG_TOML__",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use 755 instead of 0755

"user:user | 0755 | 0 | source/",
"user:user | 0755 | 0 | target/",
"user:user | 0644 | 0 | target/file.conf | from target",
],
});

await testbed.run({ args: ["--config", "config.toml", "sync"] });

testbed.assertOutput({
code: 0,
stdout: deindent`
source -> target: 0
target -> source: 0
deleted target: 0
deleted source: 0
permission skips: 1
`,
stderr: deindent`
Warning: skipping 'file.conf' (target file has unexpected permissions 644, expected 600 for this preset)
`,
});
});
27 changes: 27 additions & 0 deletions e2e-tests/test-deviating-directories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { assertEquals } from "./lib/index.ts";
import { TestBed } from "./lib/TestBed.ts";

Deno.test("deviating-directories", async (t) => {
const { testbed } = await TestBed.create(t, {
configToml: `
[[sync]]
source = "./source"
target = "./target"
globs = ["**/*.conf"]

[[sync.deviating]]
path = "./target/special-dir"
owner = "root:root"
`,
files: [
"user:user | 0755 | 0 | config.toml | __CONFIG_TOML__",
"user:user | 0755 | 0 | source/",
"user:user | 0644 | 0 | source/file.conf | hello",
"user:user | 0755 | 0 | target/",
"user:user | 0755 | 0 | target/special-dir/",
],
});

await testbed.run({ args: ["--config", "config.toml", "sync"] });
assertEquals(testbed.getExitCode(), 0);
});
Loading
Loading