#!/usr/bin/env bash

# Without a monorepo root, `--affected` keeps the tasks whose `sources` match a
# changed file, plus the tasks that declare no `sources`.

export AFFECTED_LOG="$PWD/affected.log"

cat <<'EOF' >.gitignore
affected.log
EOF

cat <<'EOF' >mise.toml
[tasks."test:unit"]
sources = ["src/**", "!src/**/*.snap"]
run = 'echo unit >> "$AFFECTED_LOG"'

[tasks."test:docs"]
sources = ["docs/**"]
run = 'echo docs >> "$AFFECTED_LOG"'

[tasks."test:smoke"]
run = 'echo smoke >> "$AFFECTED_LOG"'

[tasks."test:shared"]
dir = "app"
sources = ["../shared/*.txt"]
run = 'echo shared >> "$AFFECTED_LOG"'
EOF

mkdir -p src docs shared app
echo initial >src/lib.rs
echo initial >docs/index.md
echo initial >shared/notes.txt

git init -q
git config user.email test@example.com
git config user.name Test
git add .
git commit -qm initial

echo changed >>src/lib.rs
git add .
git commit -qm src-change

assert_json "mise run --affected --affected-json 'test:*'" '{
  "base": "HEAD~1",
  "head": "HEAD",
  "tasks": [
    {
      "name": "test:smoke",
      "reasons": [{ "type": "no-sources" }]
    },
    {
      "name": "test:unit",
      "reasons": [{ "type": "changed-source", "path": "src/lib.rs" }]
    }
  ]
}'
assert "test ! -e affected.log"

output=$(mise run --affected --affected-explain --force --jobs 1 'test:*')
assert_contains "echo \"$output\"" "Affected tasks (HEAD~1...HEAD, uncommitted, untracked):"
assert_contains "echo \"$output\"" "changed source: src/lib.rs"
assert_contains "echo \"$output\"" "no sources declared"
assert_contains "echo \"$output\"" "Unaffected tasks:"
assert "sort affected.log" $'smoke\nunit'

# A negated source pattern keeps its files out.
: >affected.log
echo changed >src/output.snap
git add .
git commit -qm snapshot-change
mise run --affected --force --jobs 1 'test:*'
assert "cat affected.log" "smoke"

# Sources resolve against the task directory, so `../` reaches a sibling.
: >affected.log
echo changed >>shared/notes.txt
git add .
git commit -qm shared-change
mise run --affected --force --jobs 1 'test:*'
assert "sort affected.log" $'shared\nsmoke'

# Working-tree changes count, and the source flags narrow them.
: >affected.log
echo changed >>docs/index.md
mise run --affected --affected-uncommitted --force --jobs 1 'test:*'
assert "sort affected.log" $'docs\nsmoke'

: >affected.log
mise run --affected --affected-committed --force --jobs 1 'test:*'
assert "sort affected.log" $'shared\nsmoke'
git checkout -q docs/index.md

: >affected.log
echo new >docs/new.md
mise run --affected --affected-untracked --force --jobs 1 'test:*'
assert "sort affected.log" $'docs\nsmoke'
rm docs/new.md

# An explicit range with no changes selects nothing, not even tasks without sources.
: >affected.log
mise run --affected --affected-base HEAD --affected-head HEAD --force --jobs 1 'test:*'
assert "test ! -s affected.log"

# Editing the file that defines the tasks affects every task in it.
: >affected.log
echo '# comment' >>mise.toml
git add .
git commit -qm config-change
assert_contains "mise run --affected --affected-explain --dry-run 'test:docs'" "changed task config: mise.toml"
mise run --affected --force --jobs 1 'test:*'
assert "sort affected.log" $'docs\nshared\nsmoke\nunit'

# --affected-base picks the range.
: >affected.log
mise run --affected --affected-base HEAD~4 --affected-head HEAD~2 --force --jobs 1 'test:*'
assert "sort affected.log" $'smoke\nunit'

# Dependencies of a selected task run even when their sources did not change.
cat <<'EOF' >>mise.toml

[tasks.ci]
sources = ["src/**"]
depends = ["test:docs"]
run = 'echo ci >> "$AFFECTED_LOG"'
EOF
git add .
git commit -qm add-ci
: >affected.log
echo changed >>src/lib.rs
mise run --affected --affected-uncommitted --force --jobs 1 ci
assert "sort affected.log" $'ci\ndocs'

# Explain names only what it compared.
git checkout -q src/lib.rs
echo changed >>docs/index.md
output=$(mise run --affected --affected-uncommitted --affected-explain --dry-run 'test:docs')
assert_contains "echo \"$output\"" "Affected tasks (uncommitted):"
assert_not_contains "echo \"$output\"" "HEAD~1...HEAD"
git checkout -q docs/index.md

# A task directory reached through a symlink matches the paths Git reports,
# and so does a literal `file` script.
mkdir -p real/src scripts
ln -s real link
echo initial >real/src/lib.rs
cat <<'EOF' >scripts/build.sh
#!/usr/bin/env bash
echo script >>"$AFFECTED_LOG"
EOF
chmod +x scripts/build.sh
cat <<'EOF' >>mise.toml

[tasks.linked]
dir = "link"
sources = ["src/**"]
run = 'echo linked >> "$AFFECTED_LOG"'

[tasks.script]
sources = ["inputs/**"]
file = "scripts/build.sh"

[tasks.args]
usage = 'arg "<name>"'
sources = ["{{usage.name}}/**"]
run = 'echo args >> "$AFFECTED_LOG"'
EOF
git add .
git commit -qm add-linked

echo changed >>real/src/lib.rs
assert_contains "mise run --affected --affected-uncommitted --affected-explain --dry-run linked" "changed source: real/src/lib.rs"
: >affected.log
mise run --affected --affected-uncommitted --force --jobs 1 linked ::: script
assert "cat affected.log" "linked"
git checkout -q real/src/lib.rs

echo '# changed' >>scripts/build.sh
assert_contains "mise run --affected --affected-uncommitted --affected-explain --dry-run script" "changed task file: scripts/build.sh"
assert_contains "mise run --affected --affected-uncommitted --affected-json args" '"type": "argument-sources"'
git checkout -q scripts/build.sh

# The `file` script is a source for the up-to-date check too, so a task that
# --affected selects for a script change is not then skipped as fresh.
cat <<'EOF' >>mise.toml

[tasks.built]
sources = ["inputs/**"]
outputs = ["built.txt"]
file = "scripts/built.sh"
EOF
mkdir -p inputs
echo initial >inputs/a.txt
cat <<'EOF' >scripts/built.sh
#!/usr/bin/env bash
echo built >>"$AFFECTED_LOG"
touch built.txt
EOF
chmod +x scripts/built.sh
echo built.txt >>.gitignore
git add .
git commit -qm add-built
mise run built
: >affected.log
mise run built
assert "test ! -s affected.log"
echo '# changed' >>scripts/built.sh
mise run --affected --affected-uncommitted built
assert "cat affected.log" "built"
git checkout -q scripts/built.sh

# Each task reads the changes of the checkout that holds it, so running from a
# nested checkout still sees the parent project's changes.
mkdir -p nested/lib
echo nested/ >>.gitignore
git add .
git commit -qm ignore-nested
cat <<'EOF' >nested/mise.toml
[tasks.inner]
sources = ["lib/**"]
run = 'echo inner >> "$AFFECTED_LOG"'
EOF
echo initial >nested/lib/a.txt
git -C nested init -q
git -C nested config user.email test@example.com
git -C nested config user.name Test
git -C nested add .
git -C nested commit -qm initial
echo changed >>src/lib.rs
echo changed >>nested/lib/a.txt
output=$(cd nested && mise run --affected --affected-uncommitted --affected-explain --dry-run 'test:unit' ::: inner)
assert_contains "echo \"$output\"" "changed source: src/lib.rs"
assert_contains "echo \"$output\"" "changed source: lib/a.txt"
git checkout -q src/lib.rs

# A submodule compares the commits its gitlink records, since the parent's
# commit IDs (such as $GITHUB_SHA in CI) do not exist there.
upstream="$HOME/sub-upstream"
mkdir -p "$upstream/lib"
echo initial >"$upstream/lib/a.txt"
git -C "$upstream" init -q
git -C "$upstream" config user.email test@example.com
git -C "$upstream" config user.name Test
git -C "$upstream" add .
git -C "$upstream" commit -qm initial
git -c protocol.file.allow=always submodule add -q "$upstream" sub
cat <<'EOF' >>mise.toml

[tasks.sub]
dir = "sub"
sources = ["lib/**"]
run = 'echo sub >> "$AFFECTED_LOG"'
EOF
git add .
git commit -qm add-sub
git -C sub config user.email test@example.com
git -C sub config user.name Test
echo changed >>sub/lib/a.txt
git -C sub commit -qam sub-change
git add sub
git commit -qm bump-sub
base=$(git rev-parse HEAD~1)
head=$(git rev-parse HEAD)
assert_contains "mise run --affected --affected-committed --affected-base $base --affected-head $head --affected-explain --dry-run sub" "changed source: sub/lib/a.txt"

# A task whose directory is in the submodule still sees changes to the parent
# config that defines it, while the submodule is clean.
echo '# comment' >>mise.toml
assert_contains "mise run --affected --affected-uncommitted --affected-explain --dry-run sub" "changed task config: mise.toml"
git checkout -q mise.toml

# Moving the submodule back to an ancestor still reports the files it undoes.
sub_new=$(git -C sub rev-parse HEAD)
git -C sub checkout -q HEAD~1
git add sub
git commit -qm rollback-sub
assert_contains "mise run --affected --affected-committed --affected-explain --dry-run sub" "changed source: sub/lib/a.txt"

# A submodule checked out at another commit than the parent records keeps its
# committed changes, and its working tree is left out.
git -C sub checkout -q "$sub_new"
echo new >sub/lib/new.txt
output=$(mise run --affected --affected-explain --dry-run sub)
assert_contains "echo \"$output\"" "changed source: sub/lib/a.txt"
assert_not_contains "echo \"$output\"" "lib/new.txt"
rm sub/lib/new.txt
git submodule update -q

# A submodule added within the range counts every file it brings in.
cat <<'EOF' >>mise.toml

[tasks.sub2]
dir = "sub2"
sources = ["lib/**"]
run = 'echo sub2 >> "$AFFECTED_LOG"'
EOF
git add .
git commit -qm add-sub2-task
git -c protocol.file.allow=always submodule add -q "$upstream" sub2
git commit -qm add-sub2
assert_contains "mise run --affected --affected-committed --affected-explain --dry-run sub2" "changed source: sub2/lib/a.txt"

# A source glob that starts above a submodule reaches the files changed in it.
cat <<'EOF' >>mise.toml

[tasks.globbed]
sources = ["sub*/lib/**"]
run = 'echo globbed >> "$AFFECTED_LOG"'
EOF
git add .
git commit -qm add-globbed
git -C sub checkout -q "$sub_new"
git add sub
git commit -qm bump-sub-again
assert_contains "mise run --affected --affected-committed --affected-explain --dry-run globbed" "changed source: sub/lib/a.txt"

# A literal source that is a symlink matches when its target changes.
mkdir -p data real-data
echo initial >real-data/config.json
ln -s ../real-data/config.json data/config.json
cat <<'EOF' >>mise.toml

[tasks.linked-source]
sources = ["data/config.json"]
run = 'echo linked-source >> "$AFFECTED_LOG"'

[tasks.templated-file]
usage = 'arg "<name>"'
sources = ["data/**"]
file = "scripts/{{usage.name}}.sh"
EOF
git add .
git commit -qm add-linked-source
echo changed >>real-data/config.json
assert_contains "mise run --affected --affected-uncommitted --affected-explain --dry-run linked-source" "changed source: real-data/config.json"

# A `file` that cannot render before its arguments are known does not abort the
# selection; its other inputs still decide.
assert_contains "mise run --affected --affected-uncommitted --affected-json linked-source ::: templated-file" '"name": "linked-source"'
git checkout -q real-data/config.json

# Excluding a symlink does not exclude its target, which another pattern includes.
cat <<'EOF' >>mise.toml

[tasks.excluded-link]
sources = ["real-data/**", "!data/config.json"]
run = 'echo excluded-link >> "$AFFECTED_LOG"'
EOF
git add mise.toml
git commit -qm add-excluded-link
echo changed >>real-data/config.json
assert_contains "mise run --affected --affected-uncommitted --affected-explain --dry-run excluded-link" "changed source: real-data/config.json"
git checkout -q real-data/config.json

# An untracked file in a submodule whose recorded commit has not changed counts.
echo new >sub/lib/untracked.txt
assert_contains "mise run --affected --affected-untracked --affected-explain --dry-run sub" "changed source: sub/lib/untracked.txt"
rm sub/lib/untracked.txt

# Running inside a submodule compares the submodule's own history.
assert_contains "cd sub && mise run --affected --affected-committed --affected-explain --dry-run sub" "changed source: lib/a.txt"

# The submodule is compared with the commit the head tree records, not a staged one.
git -C sub checkout -q HEAD~1
git add sub
git -C sub checkout -q "$sub_new"
echo new >sub/lib/untracked.txt
assert_contains "mise run --affected --affected-untracked --affected-explain --dry-run sub" "changed source: sub/lib/untracked.txt"
rm sub/lib/untracked.txt
git reset -q sub
